Cada Resource vive en su propio directorio bajo app/Filament/Resources/{Nombre}/:
php artisan boost:add-skill orebarranco/filament-ddd-starter
Save this content to: .cursor/rules/filament.md
# Filament — Patrones de este proyecto
## Estructura de un Resource
Cada Resource vive en su propio directorio bajo `app/Filament/Resources/{Nombre}/`:
```
app/Filament/Resources/Users/
├── UserResource.php ← Registro central (modelo, nav, pages)
├── Pages/
│ ├── ListUsers.php ← Override de handleRecord* para delegar a Actions
│ ├── CreateUser.php
│ └── EditUser.php
├── Schemas/
│ └── UserForm.php ← Definición del formulario (campos)
└── Tables/
└── UsersTable.php ← Definición de la tabla (columnas, filtros, acciones)
```
---
## Cómo los Resources delegan a las Domain Actions
### Creación — `CreateUser.php`
```php
final class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
protected function handleRecordCreation(array $data): Model
{
// 1. Construir DTO desde los datos del formulario
// 2. Resolver la Action desde el contenedor (NUNCA new)
// 3. Ejecutar
return resolve(CreateUserAction::class)->execute(UserData::fromArray($data));
}
}
```
### Edición — `EditUser.php`
```php
final class EditUser extends EditRecord
{
protected function handleRecordUpdate(Model $record, array $data): Model
{
/** @var User $record */
return resolve(UpdateUserAction::class)->execute($record, UserData::fromArray($data));
}
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
->action(fn (User $record) => resolve(DeleteUserAction::class)->execute($record)),
];
}
}
```
### Eliminación inline en tabla — `UsersTable.php`
```php
DeleteAction::make()
->using(fn (User $record, DeleteUserAction $deleteUserAction): bool =>
$deleteUserAction->execute($record)
),
// Filament inyecta DeleteUserAction automáticamente desde el contenedor
```
---
## Dónde va cada cosa
| Qué | Dónde |
|-----|-------|
| Campos del formulario, labels, validación UI | `Schemas/UserForm.php` |
| Columnas, filtros, ordenamiento, acciones de tabla | `Tables/UsersTable.php` |
| Navegación, modelo, páginas registradas | `UserResource.php` |
| Lógica de creación / actualización / eliminación | Domain Actions |
| Transformación de datos de formulario a DTO | `UserData::fromArray($data)` en la Page |
| Autorización | `app/Policies/UserPolicy.php` (Spatie Permission) |
---
## Patrones observados en el código existente
### Form separado en clase propia
```php
// UserResource.php
public static function form(Schema $schema): Schema
{
return UserForm::configure($schema); // Delega a clase dedicada
}
```
No se define el formulario inline en el Resource. Siempre en `Schemas/`.
### Table separada en clase propia
```php
public static function table(Table $table): Table
{
return UsersTable::configure($table); // Delega a clase dedicada
}
```
### Password condicional en formulario
```php
TextInput::make('password')
->required(fn (string $operation): bool => $operation === 'create')
->dehydrated(fn (?string $state): bool => filled($state))
```
El campo es requerido solo en creación y no se envía si está vacío en edición.
### Roles con Select múltiple
```php
Select::make('roles')
->multiple()
->relationship(titleAttribute: 'name')
->preload()
->searchable(),
```
Filament gestiona la relación many-to-many directamente.
### Iconos con el enum de Filament
```php
use Filament\Support\Icons\Heroicon;
protected static string|null|BackedEnum $navigationIcon = Heroicon::OutlinedUsers;
```
Siempre usar `Heroicon::` enum, nunca strings.
---
## Namespaces correctos en Filament 5
| Tipo | Namespace |
|------|-----------|
| Form fields (`TextInput`, `Select`…) | `Filament\Forms\Components\` |
| Infolist entries (`TextEntry`…) | `Filament\Infolists\Components\` |
| Layout (`Grid`, `Section`, `Tabs`…) | `Filament\Schemas\Components\` |
| Utilities (`Get`, `Set`) | `Filament\Schemas\Components\Utilities\` |
| Actions (todas) | `Filament\Actions\` |
| Columnas de tabla | `Filament\Tables\Columns\` |
---
## Autorización con Filament Shield
Las policies **no se crean a mano**. Shield las genera automáticamente:
```bash
# Al crear un nuevo Resource
php artisan shield:generate --resource=NombreResource --panel=admin --no-interaction
# Regenerar todo (si se añaden resources/pages/widgets)
php artisan shield:generate --all --panel=admin --ignore-existing-policies --no-interaction
# Y siempre después: volcar los permisos al seeder versionado
php artisan shield:seeder --force --no-interaction
```
### `#[UsePolicy]` también blinda la policy contra la regeneración
El atributo tiene un segundo efecto que no está en la documentación de Shield y
que conviene conocer antes de ejecutar un `shield:generate --all`: Shield detecta
que la policy está provista explícitamente y **salta la generación**. La salida
lo dice tal cual:
```
UserPolicy .. skipped — provided by App\Policies\UserPolicy
```
`RolePolicy` no tiene esa protección y se regenera siempre, porque el modelo es
`Spatie\Permission\Models\Role` y a una clase de vendor no le puedes poner el
atributo. Al regenerarla pierde `final`, recupera parámetros de modelo sin usar y
se queda sin newline final: deshace el trabajo de Pint y de Rector. Si ejecutas
`--all`, revisa el diff de esa policy antes de commitear.
Por eso `shield:generate` **no** vive en `composer setup`: es un generador de
código, y el arranque de una instalación no debe reescribir `app/`. Los permisos
llegan como dato, vía `ShieldSeeder`.
Patrón generado (ejemplo real de `UserPolicy`):
```php
public function viewAny(AuthUser $authUser): bool
{
return $authUser->can('ViewAny:User'); // Spatie Permission
}
```
Convención de nombres de permiso: `{Acción}:{Modelo}` — e.g. `ViewAny:User`, `Delete:User`.
El rol `super_admin` tiene acceso irrestricto (configurado en `config/filament-shield.php`).
---
## Acceso al panel: dos efectos que no se ven en el código
`User::canAccessPanel()` exige cuenta activa y al menos un rol. Dos funcionalidades del
panel consultan ese método por su cuenta, y el resultado sorprende si no se sabe.
### La recuperación de contraseña no avisa a quien no puede entrar
`Filament\Auth\Pages\PasswordReset\RequestPasswordReset` comprueba `canAccessPanel()`
antes de enviar el correo y, si falla, **no envía nada y no informa de por qué**. El
formulario responde igual que si lo hubiera enviado, a propósito, para no revelar qué
direcciones existen.
Consecuencia práctica: un usuario sin rol, o suspendido, pide el enlace y no llega nunca.
No está roto. Si hay que darle acceso, se le asigna un rol o se reactiva la cuenta; el
enlace no es la vía.
### La suplantación exige que el objetivo pueda entrar solo
`canBeImpersonated()` requiere cuenta activa y rol por el mismo motivo. Sin esa condición
la suplantación cambia el guard y aterriza en un 403, y desde ahí la única salida es el
banner de volver. Se comprueba antes para que la acción ni aparezca.
Un `super_admin` no puede suplantar a otro `super_admin`: no aporta nada para dar soporte
y difumina quién actuó de verdad.
El vendor evalúa `canImpersonate()` del actor **antes** que `canBeImpersonated()` del
objetivo (`STS\FilamentImpersonate\Actions\Impersonate`). Ese orden no está versionado,
así que los tests de `tests/Unit/Domain/Identity/Models/UserTest.php` lo cubren por los
dos lados.
Y un aviso operativo: con `MAIL_MAILER=log`, que es el valor de `.env.example`, el correo
de recuperación se escribe en `storage/logs/laravel.log` en vez de enviarse. En una
instalación limpia es ahí donde hay que buscarlo.
---
## Lo que NO se hace
- **No** poner lógica de negocio en un Resource, Page, Form o Table.
- **No** llamar a `User::create()` directamente desde una Page. Siempre vía Action.
- **No** usar `new CreateUserAction()`. Siempre `resolve()` o inyección de dependencias.
- **No** definir columnas de tabla inline en `UserResource::table()`. Van en `Tables/UsersTable.php`.
- **No** usar `Filament\Tables\Actions\DeleteAction`. La clase correcta es `Filament\Actions\DeleteAction`.
Cada Resource vive en su propio directorio bajo app/Filament/Resources/{Nombre}/:
app/Filament/Resources/Users/
├── UserResource.php ← Registro central (modelo, nav, pages)
├── Pages/
│ ├── ListUsers.php ← Override de handleRecord* para delegar a Actions
│ ├── CreateUser.php
│ └── EditUser.php
├── Schemas/
│ └── UserForm.php ← Definición del formulario (campos)
└── Tables/
└── UsersTable.php ← Definición de la tabla (columnas, filtros, acciones)
CreateUser.phpfinal class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
protected function handleRecordCreation(array $data): Model
{
// 1. Construir DTO desde los datos del formulario
// 2. Resolver la Action desde el contenedor (NUNCA new)
// 3. Ejecutar
return resolve(CreateUserAction::class)->execute(UserData::fromArray($data));
}
}
EditUser.phpfinal class EditUser extends EditRecord
{
protected function handleRecordUpdate(Model $record, array $data): Model
{
/** @var User $record */
return resolve(UpdateUserAction::class)->execute($record, UserData::fromArray($data));
}
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
->action(fn (User $record) => resolve(DeleteUserAction::class)->execute($record)),
];
}
}
UsersTable.phpDeleteAction::make()
->using(fn (User $record, DeleteUserAction $deleteUserAction): bool =>
$deleteUserAction->execute($record)
),
// Filament inyecta DeleteUserAction automáticamente desde el contenedor
| Qué | Dónde |
|---|---|
| Campos del formulario, labels, validación UI | Schemas/UserForm.php |
| Columnas, filtros, ordenamiento, acciones de tabla | Tables/UsersTable.php |
| Navegación, modelo, páginas registradas | UserResource.php |
| Lógica de creación / actualización / eliminación | Domain Actions |
| Transformación de datos de formulario a DTO | UserData::fromArray($data) en la Page |
| Autorización | app/Policies/UserPolicy.php (Spatie Permission) |
// UserResource.php
public static function form(Schema $schema): Schema
{
return UserForm::configure($schema); // Delega a clase dedicada
}
No se define el formulario inline en el Resource. Siempre en Schemas/.
public static function table(Table $table): Table
{
return UsersTable::configure($table); // Delega a clase dedicada
}
TextInput::make('password')
->required(fn (string $operation): bool => $operation === 'create')
->dehydrated(fn (?string $state): bool => filled($state))
El campo es requerido solo en creación y no se envía si está vacío en edición.
Select::make('roles')
->multiple()
->relationship(titleAttribute: 'name')
->preload()
->searchable(),
Filament gestiona la relación many-to-many directamente.
use Filament\Support\Icons\Heroicon;
protected static string|null|BackedEnum $navigationIcon = Heroicon::OutlinedUsers;
Siempre usar Heroicon:: enum, nunca strings.
| Tipo | Namespace |
|---|---|
Form fields (TextInput, Select…) |
Filament\Forms\Components\ |
Infolist entries (TextEntry…) |
Filament\Infolists\Components\ |
Layout (Grid, Section, Tabs…) |
Filament\Schemas\Components\ |
Utilities (Get, Set) |
Filament\Schemas\Components\Utilities\ |
| Actions (todas) | Filament\Actions\ |
| Columnas de tabla | Filament\Tables\Columns\ |
Las policies no se crean a mano. Shield las genera automáticamente:
# Al crear un nuevo Resource
php artisan shield:generate --resource=NombreResource --panel=admin --no-interaction
# Regenerar todo (si se añaden resources/pages/widgets)
php artisan shield:generate --all --panel=admin --ignore-existing-policies --no-interaction
# Y siempre después: volcar los permisos al seeder versionado
php artisan shield:seeder --force --no-interaction
#[UsePolicy] también blinda la policy contra la regeneraciónEl atributo tiene un segundo efecto que no está en la documentación de Shield y
que conviene conocer antes de ejecutar un shield:generate --all: Shield detecta
que la policy está provista explícitamente y salta la generación. La salida
lo dice tal cual:
UserPolicy .. skipped — provided by App\Policies\UserPolicy
RolePolicy no tiene esa protección y se regenera siempre, porque el modelo es
Spatie\Permission\Models\Role y a una clase de vendor no le puedes poner el
atributo. Al regenerarla pierde final, recupera parámetros de modelo sin usar y
se queda sin newline final: deshace el trabajo de Pint y de Rector. Si ejecutas
--all, revisa el diff de esa policy antes de commitear.
Por eso shield:generate no vive en composer setup: es un generador de
código, y el arranque de una instalación no debe reescribir app/. Los permisos
llegan como dato, vía ShieldSeeder.
Patrón generado (ejemplo real de UserPolicy):
public function viewAny(AuthUser $authUser): bool
{
return $authUser->can('ViewAny:User'); // Spatie Permission
}
Convención de nombres de permiso: {Acción}:{Modelo} — e.g. ViewAny:User, Delete:User.
El rol super_admin tiene acceso irrestricto (configurado en config/filament-shield.php).
User::canAccessPanel() exige cuenta activa y al menos un rol. Dos funcionalidades del
panel consultan ese método por su cuenta, y el resultado sorprende si no se sabe.
Filament\Auth\Pages\PasswordReset\RequestPasswordReset comprueba canAccessPanel()
antes de enviar el correo y, si falla, no envía nada y no informa de por qué. El
formulario responde igual que si lo hubiera enviado, a propósito, para no revelar qué
direcciones existen.
Consecuencia práctica: un usuario sin rol, o suspendido, pide el enlace y no llega nunca. No está roto. Si hay que darle acceso, se le asigna un rol o se reactiva la cuenta; el enlace no es la vía.
canBeImpersonated() requiere cuenta activa y rol por el mismo motivo. Sin esa condición
la suplantación cambia el guard y aterriza en un 403, y desde ahí la única salida es el
banner de volver. Se comprueba antes para que la acción ni aparezca.
Un super_admin no puede suplantar a otro super_admin: no aporta nada para dar soporte
y difumina quién actuó de verdad.
El vendor evalúa canImpersonate() del actor antes que canBeImpersonated() del
objetivo (STS\FilamentImpersonate\Actions\Impersonate). Ese orden no está versionado,
así que los tests de tests/Unit/Domain/Identity/Models/UserTest.php lo cubren por los
dos lados.
Y un aviso operativo: con MAIL_MAILER=log, que es el valor de .env.example, el correo
de recuperación se escribe en storage/logs/laravel.log en vez de enviarse. En una
instalación limpia es ahí donde hay que buscarlo.
User::create() directamente desde una Page. Siempre vía Action.new CreateUserAction(). Siempre resolve() o inyección de dependencias.UserResource::table(). Van en Tables/UsersTable.php.Filament\Tables\Actions\DeleteAction. La clase correcta es Filament\Actions\DeleteAction.How can I help you explore Laravel packages today?