Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
Filament
orebarranco/filament-ddd-starter
cursor
filament
domain-actions
dtos
form-schemas
table-actions
policies
resource-structure

Cada Resource vive en su propio directorio bajo app/Filament/Resources/{Nombre}/:

Install
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`.

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

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

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

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

// 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

public static function table(Table $table): Table
{
    return UsersTable::configure($table);  // Delega a clase dedicada
}

Password condicional en formulario

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

Select::make('roles')
    ->multiple()
    ->relationship(titleAttribute: 'name')
    ->preload()
    ->searchable(),

Filament gestiona la relación many-to-many directamente.

Iconos con el enum de Filament

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:

# 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):

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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console