بنقرة واحدة
create-crud
Generates a complete CRUD module following the boilerplate conventions.
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
القائمة
Generates a complete CRUD module following the boilerplate conventions.
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
استنادا إلى تصنيف SOC المهني
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.
| 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}