con un clic
create-crud
Generates a complete CRUD module following the boilerplate conventions.
Instalar con Codex o Claude Copia este prompt, pégalo en Codex, Claude u otro asistente, y deja que revise la página de la skill y la instale por ti.
Menú
Generates a complete CRUD module following the boilerplate conventions.
Instalar con Codex o Claude Copia este prompt, pégalo en Codex, Claude u otro asistente, y deja que revise la página de la skill y la instale por ti.
Basado en la clasificación ocupacional SOC
| name | create-crud |
| description | Generates a complete CRUD module following the boilerplate conventions. |
| license | MIT |
| compatibility | claude_code, codex, cursor, opencode |
Activate when the user asks to create a new module with listing, create and edit. Trigger phrases: "crear crud", "nuevo módulo con tabla", "generar módulo", "create crud".
use SoftDeletes and $table->softDeletes().forceDelete. No button, route, or method should call forceDelete().withTrashed()) and shows Restaurar for them.x-ts-table from TallStackUI with native Livewire state.x-ts-table is used without built-in filter/quantity/paginate props — all handled manually.x-teleport="#app-root" + position:fixed to escape overflow clipping.AppServiceProvider. Never override this background manually — just use <x-slot:footer> normally.| Variable | Description | Example |
|---|---|---|
{Model} | PascalCase singular | Product |
{model} | camelCase singular | product |
{models} | snake_case plural | products |
{model-slug} | kebab-case plural (URLs) | products |
{Domain} | PascalCase domain | General |
{domain} | lowercase domain | general |
{Domain_es} | Spanish domain name capitalized | Personal |
{model_es} | Spanish plural (used in permissions) | productos |
{Model_es} | Spanish singular capitalized | Producto |
{models_es} | Spanish plural | productos |
{Models_es} | Spanish plural capitalized | Productos |
{icon} | Lucide icon name for the module | package |
{Fields} | Fields with types | name string, price decimal |
Create a task list before writing any file. Mark each step as completed.
ddev exec php artisan make:model {Model} -mf
ddev exec php artisan livewire:form {Model}Form
ddev exec php artisan livewire:make App/{Domain}/{Model}/Form --no-view
ddev exec php artisan livewire:make App/{Domain}/{Model}/Table --no-view
app/Models/{Model}.php<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class {Model} extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['name'];
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('{models}', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('{models}');
}
};
database/factories/{Model}Factory.php<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class {Model}Factory extends Factory
{
public function definition(): array
{
return [
'name' => $this->faker->sentence(3),
];
}
}
app/Livewire/Forms/{Model}Form.php<?php
namespace App\Livewire\Forms;
use App\Models\{Model};
use Livewire\Form;
class {Model}Form extends Form
{
public ?int $id = null;
public string $name = '';
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
];
}
public function store(): {Model}
{
return {Model}::updateOrCreate(
['id' => $this->id],
$this->only('name'),
);
}
}
app/Livewire/App/{Domain}/{Model}/Form.php<?php
namespace App\Livewire\App\{Domain}\{Model};
use App\Livewire\Forms\{Model}Form;
use App\Models\{Model};
use Livewire\Component;
use TallStackUi\Traits\Interactions;
class Form extends Component
{
use Interactions;
public ?{Model} $record = null;
public {Model}Form $form;
public function mount(): void
{
if ($this->record) {
$this->form->fill([
'id' => $this->record->id,
'name' => $this->record->name,
]);
}
}
public function save(): void
{
$this->form->validate();
$isEdit = $this->form->id !== null;
$this->form->store();
$this->toast()
->success('Éxito', $isEdit
? '{Model_es} actualizado correctamente.'
: '{Model_es} creado correctamente.'
)
->flash()
->send();
$this->redirect(route('{domain}.{model-slug}.index'), navigate: true);
}
public function render()
{
return view('app.{domain}.{model-slug}._form');
}
}
app/Livewire/App/{Domain}/{Model}/Table.phpThe table uses native Livewire state for search, sort, quantity, and filters. Add filter properties only for the fields the module requires.
<?php
namespace App\Livewire\App\{Domain}\{Model};
use App\Models\{Model};
use App\Traits\Livewire\HasSoftDeletes;
use Livewire\Component;
use Livewire\WithPagination;
use TallStackUi\Traits\Interactions;
class Table extends Component
{
use Interactions, WithPagination, HasSoftDeletes;
protected string $modelClass = {Model}::class;
protected string $deletePermission = 'eliminar {model_es}';
protected string $restorePermission = 'restaurar {model_es}';
protected string $modelLabel = '{Model_es}';
public string $search = '';
// Add filter properties as needed:
// public string $filterName = '';
public int $quantity = 25;
public array $sort = ['column' => 'name', 'direction' => 'asc'];
public function updatingSearch(): void
{
$this->resetPage();
}
public function updatingQuantity(): void
{
$this->resetPage();
}
// Add updatingFilter* methods for each filter:
// public function updatingFilterName(): void { $this->resetPage(); }
public function clearFilters(): void
{
// $this->reset('filterName');
$this->resetPage();
}
public function render()
{
$headers = [
['index' => 'name', 'label' => 'Nombre'],
['index' => 'status', 'label' => 'Estado', 'sortable' => false],
['index' => 'action', 'label' => 'Acciones', 'sortable' => false],
];
${models} = {Model}::withTrashed()
->when($this->search, fn ($q) => $q->where(function ($q) {
$q->where('name', 'ilike', "%{$this->search}%");
}))
// ->when($this->filterName, fn ($q) => $q->where('name', 'ilike', "%{$this->filterName}%"))
->orderBy($this->sort['column'], $this->sort['direction'])
->paginate($this->quantity);
${models}->getCollection()->transform(function ({Model} $item) {
$item->status = $item->trashed() ? 'Eliminado' : 'Activo';
return $item;
});
// Pass filter option arrays as needed:
// $options = SomeModel::orderBy('name')->pluck('name', 'name')->toArray();
return view('app.{domain}.{model-slug}._index', compact('headers', '{models}'));
}
}
routes/{domain}.phpAdd inside the domain route group:
use App\Models\{Model};
Route::prefix('{model-slug}')->name('{model-slug}.')->group(function () {
Route::middleware('permission:ver {model_es}')
->get('/', fn () => view('app.{domain}.{model-slug}.index'))
->name('index');
Route::middleware('permission:crear {model_es}')
->get('/create', fn () => view('app.{domain}.{model-slug}.create'))
->name('create');
Route::middleware('permission:editar {model_es}')
->get('/{model}/edit', fn ({Model} ${model}) => view(
'app.{domain}.{model-slug}.edit',
['{model}' => ${model}]
))
->name('edit');
});
All wrappers pass icon, parent (domain label), and title to the layout.
resources/views/app/{domain}/{model-slug}/index.blade.php<x-layouts.app icon="{icon}" parent="{Domain_es}" title="{Models_es}">
{{ Breadcrumbs::render('{domain}.{model-slug}.index') }}
@livewire('app.{domain}.{model-slug}.table')
</x-layouts.app>
resources/views/app/{domain}/{model-slug}/create.blade.php<x-layouts.app icon="{icon}" parent="{Domain_es}" title="Nuevo {model_es}">
{{ Breadcrumbs::render('{domain}.{model-slug}.create') }}
@livewire('app.{domain}.{model-slug}.form')
</x-layouts.app>
resources/views/app/{domain}/{model-slug}/edit.blade.php<x-layouts.app icon="{icon}" parent="{Domain_es}" title="Editar {model_es}">
{{ Breadcrumbs::render('{domain}.{model-slug}.edit', ${model}) }}
@livewire('app.{domain}.{model-slug}.form', ['record' => ${model}])
</x-layouts.app>
resources/views/app/{domain}/{model-slug}/_form.blade.php<div>
<form wire:submit="save">
<x-ts-card>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<x-ts-input
label="Nombre"
wire:model="form.name"
placeholder="Ingresa un nombre"
/>
</div>
<x-slot:footer>
<div class="flex items-center justify-end gap-3">
<a href="{{ route('{domain}.{model-slug}.index') }}" wire:navigate
class="text-sm text-content-muted hover:text-content">
Cancelar
</a>
<x-ts-button type="submit" wire:loading.attr="disabled">
Guardar
</x-ts-button>
</div>
</x-slot:footer>
</x-ts-card>
</form>
</div>
resources/views/app/{domain}/{model-slug}/_index.blade.phpThis is the main table view. It follows this layout:
x-ts-table without built-in filter/quantity/paginate<div x-data="{ showFilters: false }">
{{-- ── Barra superior ──────────────────────────────────────────────── --}}
<div class="mb-3 flex items-center gap-2">
{{-- Búsqueda --}}
<div class="relative w-64">
<x-ui.icon name="search"
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-content-subtle" />
<input
wire:model.live.debounce.400ms="search"
type="search"
placeholder="Buscar..."
class="w-full rounded-lg border border-line bg-panel py-2 pl-9 pr-4 text-sm text-content placeholder-content-subtle focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-panel"
/>
</div>
{{-- Botón filtros (mostrar solo si hay filtros definidos) --}}
<button
type="button"
@click="showFilters = !showFilters"
:class="showFilters || @js($filterName)
? 'border-primary-500 bg-primary-50 text-primary-600 dark:bg-primary-950 dark:text-primary-400'
: 'border-line bg-panel text-content-muted hover:bg-panel-alt hover:text-content dark:bg-panel'"
class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border px-3 py-2 text-sm transition-colors"
>
<x-ui.icon name="sliders-horizontal" class="size-4" />
Filtros
@if ($filterName)
<span class="flex size-2 rounded-full bg-primary-500"></span>
@endif
</button>
{{-- Separador --}}
<div class="flex-1"></div>
{{-- Botón nueva acción --}}
@can('crear {model_es}')
<a href="{{ route('{domain}.{model-slug}.create') }}" wire:navigate
class="inline-flex shrink-0 items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white"
style="background: linear-gradient(135deg, #f53003 0%, #c0392b 100%);">
<x-ui.icon name="plus" class="size-4" />
Nuevo {model_es}
</a>
@endcan
</div>
{{-- ── Panel de filtros ────────────────────────────────────────────── --}}
<div
x-show="showFilters"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
x-cloak
class="mb-4 overflow-hidden rounded-lg border border-line bg-panel shadow-sm"
>
{{-- Cabecera del panel --}}
<div class="flex items-center justify-between border-b border-line bg-panel-alt px-4 py-2.5">
<div class="flex items-center gap-2 text-sm font-medium text-content-muted">
<x-ui.icon name="sliders-horizontal" class="size-3.5" />
Filtros
</div>
@php $activeCount = (int) (bool) $filterName; @endphp
@if ($activeCount)
<span class="inline-flex items-center rounded-full bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-950 dark:text-primary-300">
{{ $activeCount }} {{ Str::plural('activo', $activeCount) }}
</span>
@endif
</div>
{{-- Campos --}}
<div class="flex flex-wrap items-end gap-3 p-4">
<x-ui.ts-table.filter-input
label="Nombre"
icon="search"
wire:model.live.debounce.400ms="filterName"
placeholder="Filtrar por nombre..."
/>
{{-- Para filtros de selección (ej: estado, rol, categoría): --}}
{{-- <x-ui.ts-table.filter-select
label="Estado"
wire:model.live="filterStatus"
placeholder="Todos"
:options="$statusOptions"
/> --}}
@if ($filterName)
<div class="group relative self-end">
<button
type="button"
wire:click="clearFilters"
class="flex cursor-pointer items-center justify-center rounded-md border border-red-200 bg-red-50 p-2 text-red-600 transition-colors hover:border-red-300 hover:bg-red-100 dark:border-red-800 dark:bg-red-950 dark:text-red-400 dark:hover:border-red-700 dark:hover:bg-red-900"
>
<x-ui.icon name="rotate-ccw" class="size-3.5" />
</button>
<div class="pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 whitespace-nowrap rounded-md bg-gray-800 px-2 py-1 text-xs text-white opacity-0 transition-opacity duration-150 group-hover:opacity-100 dark:bg-dark-600">
Limpiar filtros
</div>
</div>
@endif
</div>
</div>
{{-- ── Tabla ────────────────────────────────────────────────────────── --}}
<x-ts-table
:headers="$headers"
:rows="${models}"
:sort="$sort"
striped
>
@interact('column_status', $row)
@if ($row->trashed())
<x-ts-badge text="Eliminado" color="red" />
@else
<x-ts-badge text="Activo" color="green" />
@endif
@endinteract
@interact('column_action', $row)
<x-ui.ts-table.actions
:row="$row"
edit-route="{domain}.{model-slug}.edit"
edit-permission="editar {model_es}"
delete-permission="eliminar {model_es}"
restore-permission="restaurar {model_es}"
model="{model_es}"
/>
@endinteract
</x-ts-table>
{{-- ── Footer: conteo + paginador ──────────────────────────────────── --}}
@if (${models}->total() > 0)
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
{{-- Selector de cantidad --}}
<div class="flex items-center gap-1.5 text-sm text-content-muted">
Mostrando
<select
wire:model.live="quantity"
class="rounded-md border border-line bg-panel px-2 py-1 text-sm text-content focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
resultados de {{ ${models}->total() }}
</div>
{{-- Paginador --}}
@if (${models}->hasPages())
{{ ${models}->links('tallstack-ui::components.table.paginators') }}
@endif
</div>
@endif
</div>
These reusable components live in resources/views/components/ui/ts-table/.
x-ui.ts-table.filter-inputText filter with optional leading icon.
<x-ui.ts-table.filter-input
label="Correo"
icon="search"
wire:model.live.debounce.400ms="filterEmail"
placeholder="Filtrar por correo..."
/>
Props: label, placeholder (default ''), icon (optional, Lucide name).
x-ui.ts-table.filter-selectCustom select with search, teleported panel (escapes overflow), and $wire.entangle binding.
<x-ui.ts-table.filter-select
label="Rol"
wire:model.live="filterRole"
placeholder="Todos los roles"
:options="$roles"
/>
Props: label, placeholder (default 'Todos'), options (associative array [value => label]).
The options array is typically built in the Livewire component:
$roles = Role::orderBy('name')->pluck('name', 'name')->toArray();
x-ui.ts-table.actionsDropdown action menu (edit / restore / delete) with dialog confirmation, teleported panel.
<x-ui.ts-table.actions
:row="$row"
edit-route="{domain}.{model-slug}.edit"
edit-permission="editar {model_es}"
delete-permission="eliminar {model_es}"
restore-permission="restaurar {model_es}"
model="{model_es}"
/>
The Livewire Table component must use HasSoftDeletes trait which provides softDelete(), restore(), confirmDelete(), and confirmRestore().
routes/breadcrumbs.phpThe domain level is a non-linked label (no route). dashboard is NOT a parent for domain modules.
// {models_es}
Breadcrumbs::for('{domain}.{model-slug}.index', function (BreadcrumbTrail $trail) {
$trail->push('{Domain_es}');
$trail->push('{Models_es}', route('{domain}.{model-slug}.index'));
});
Breadcrumbs::for('{domain}.{model-slug}.create', function (BreadcrumbTrail $trail) {
$trail->parent('{domain}.{model-slug}.index');
$trail->push('Nuevo {model_es}', route('{domain}.{model-slug}.create'));
});
Breadcrumbs::for('{domain}.{model-slug}.edit', function (BreadcrumbTrail $trail, {Model} ${model}) {
$trail->parent('{domain}.{model-slug}.index');
$trail->push(${model}->name, route('{domain}.{model-slug}.edit', ${model}));
});
If routes/breadcrumbs.php does not exist yet, create it:
<?php
use Diglactic\Breadcrumbs\Breadcrumbs;
use Diglactic\Breadcrumbs\Generator as BreadcrumbTrail;
config/menu.php[
'label' => '{Models_es}',
'icon' => '{icon}',
'route' => '{domain}.{model-slug}.index',
'active_route' => '{domain}.{model-slug}.*',
'permission' => 'ver {model_es}',
],
Add to config/roles.php under permissions:
'{model_es}' => [
'ver {model_es}',
'crear {model_es}',
'editar {model_es}',
'eliminar {model_es}',
'restaurar {model_es}',
],
Then seed: ddev exec php artisan db:seed --class=RolesAndPermissionsSeeder
feat: CRUD {Models_es}
- Modelo `{Model}` con SoftDeletes y factory
- Migración tabla `{models}` con campos: {Fields}
- Form object `{Model}Form` con validación y updateOrCreate
- Componente `Form` (create/edit) con toast de confirmación
- Tabla nativa Livewire `Table` con búsqueda, filtros, sort, paginación, soft delete y restore
- Vistas: index, create, edit (wrappers con icon/parent/title) + _form, _index
- Panel de filtros colapsable con filter-input y filter-select
- Footer con selector de cantidad y paginador TallStackUI
- Rutas protegidas por permiso en routes/{domain}.php
- Breadcrumbs con dominio como etiqueta no enlazada
- Entrada en config/menu.php
- Permisos: ver, crear, editar, eliminar, restaurar {model_es}
Enforces Spanish Conventional Commits and branch naming for this project.
Creates a Laravel MCP server with tools, resources and prompts using laravel/mcp.
Creates a domain or a module within an existing domain following the boilerplate conventions.
Develops reactive Livewire 4 components. Activates when creating, updating, or modifying Livewire components; working with wire:model, wire:click, wire:loading, or any wire: directives; adding real-time updates, loading states, or reactivity; debugging component behavior; or when the user mentions Livewire, component, or reactive UI.