---
name: php-experto
description: >
  PHP moderno con Laravel: Service Container, Eloquent ORM, Form Requests,
  API Resources, Events, Jobs/Queues, Middleware y Sanctum. Cargar cuando
  se implementen APIs Laravel, modelos Eloquent, jobs asíncronos o autenticación.
version: "1.0.0"
herramientasPermitidas: [Read, Grep]
exclusiones:
  - "No cargar para diseñar patrones Laravel (Action classes, DTOs readonly, Pipeline) — para diseño cargar `php-patrones`."
  - "No cargar para escribir tests PHPUnit o Pest — para testing cargar `php-testing`."
  - "No cargar para errores de compilación PHP o Composer — para build errors cargar `build-errors-php`."
  - "No cargar para PHP sin Laravel (vanilla PHP, Symfony, Slim) — este skill cubre patrones idiomáticos de Laravel específicamente."
evolvable: true  # default para skill estandar
---
# PHP Experto — Laravel Moderno

Laravel es un framework con opiniones fuertes. Este skill cubre los patrones
idiomáticos de Laravel 10/11: inyección de dependencias via Service Container,
Eloquent con relaciones complejas, Jobs con Horizon y autenticación con Sanctum.

## Cuándo cargar este skill

Invoca `Skill("php-experto")` cuando:

- Se implementen endpoints en una API Laravel (controllers, routes)
- Se definan modelos Eloquent con relaciones o query scopes
- Se escriban Form Requests para validación
- Se creen Jobs, Events o Listeners
- Se configure autenticación con Laravel Sanctum
- Se implementen Artisan commands custom

## Cuándo NO cargar

- La pregunta es sobre diseñar patrones Laravel (Action classes, DTOs readonly, Pipeline) — para diseño cargar `php-patrones`.
- La tarea es escribir tests PHPUnit o Pest — cargar `php-testing`.
- Los errores son de Composer, autoload o extensiones PHP — cargar `build-errors-php`.
- El proyecto usa PHP sin Laravel (Symfony, Slim, vanilla PHP) — este skill cubre patrones idiomáticos de Laravel específicamente.

## Conceptos clave

### Service Container y Dependency Injection

El Service Container resuelve dependencias automáticamente. Inyectar en constructores
de controllers y services — nunca instanciar con `new` dentro de métodos de negocio.

### Eloquent Relationships

Eloquent soporta `hasOne`, `hasMany`, `belongsTo`, `belongsToMany`, `morphTo` y
variantes polimórficas. Cada relación tiene un método que retorna el Builder,
no la colección — la colección se obtiene accediendo a la propiedad.

### Query Scopes

Los scopes encapsulan condiciones de query reutilizables. Los `local scopes` se
activan con un prefijo `scope` y se encadenan como métodos. Los `global scopes`
se aplican automáticamente a todas las queries del modelo.

## Reglas obligatorias

### Usar Form Requests para validación — NUNCA validar en el Controller

```php
// MAL — logica de validacion en el controller
public function store(Request $request) {
    $request->validate([...]);
    // ...
}

// BIEN — Form Request separado
public function store(StoreProductoRequest $request) {
    // $request ya viene validado y autorizado
    $data = $request->validated();
}
```

**Por qué**: Los Form Requests permiten reutilizar validación, separar autorización
y tienen método `prepareForValidation` para normalizar inputs antes de validar.

### Eager loading OBLIGATORIO para relaciones en colecciones

```php
// MAL — N+1 queries
$pedidos = Pedido::all();
foreach ($pedidos as $pedido) {
    echo $pedido->cliente->nombre; // query por cada pedido
}

// BIEN — 2 queries sin importar el tamano de la coleccion
$pedidos = Pedido::with('cliente')->get();
```

**Cómo verificar**: Activar `DB::listen` en desarrollo o usar Telescope/Debugbar
para contar queries. Más de 2 queries para una colección es señal de N+1.

### API Resources para serializar respuestas — NUNCA retornar modelos directamente

```php
// MAL
return response()->json($usuario); // expone todos los campos incluidos sensibles

// BIEN
return new UsuarioResource($usuario);
return UsuarioResource::collection($usuarios);
```

### Jobs SIEMPRE en queues para operaciones costosas (>200ms)

```php
// Dispatch a queue, no sincrono
EnviarEmailBienvenida::dispatch($usuario)->onQueue('emails');

// Con reintento y delay
EnviarFactura::dispatch($factura)
    ->delay(now()->addMinutes(5))
    ->onQueue('documentos');
```

### Bindings en TODAS las queries raw — NUNCA interpolacion

```php
// MAL — SQL injection
DB::statement("UPDATE usuarios SET rol = '{$rol}' WHERE id = {$id}");

// BIEN — parametros nombrados
DB::statement("UPDATE usuarios SET rol = :rol WHERE id = :id", [
    'rol' => $rol,
    'id'  => $id,
]);
```

## Patrones recomendados

### Eloquent Relationships correctamente definidas

```php
class Pedido extends Model
{
    // hasMany: un pedido tiene muchos items
    public function items(): HasMany
    {
        return $this->hasMany(ItemPedido::class);
    }

    // belongsTo: un pedido pertenece a un cliente
    public function cliente(): BelongsTo
    {
        return $this->belongsTo(Cliente::class);
    }

    // morphTo: comentarios polimorficos
    public function comentarios(): MorphMany
    {
        return $this->morphMany(Comentario::class, 'comentable');
    }
}
```

### Query Scopes para filtros reutilizables

```php
class Producto extends Model
{
    // Local scope: $query->activo()
    public function scopeActivo(Builder $query): Builder
    {
        return $query->where('activo', true);
    }

    // Scope con parametro: $query->precioMenorA(500)
    public function scopePrecioMenorA(Builder $query, float $precio): Builder
    {
        return $query->where('precio', '<', $precio);
    }
}

// Uso encadenado
$productos = Producto::activo()->precioMenorA(500)->with('categoria')->get();
```

### Event + Listener desacoplados

```php
// Disparar el evento en el service
event(new PedidoCreado($pedido));

// El listener se encola automaticamente si implementa ShouldQueue
class NotificarClientePedidoCreado implements ShouldQueue
{
    public function handle(PedidoCreado $event): void
    {
        $event->pedido->cliente->notify(new PedidoCreadoNotification($event->pedido));
    }
}
```

### Sanctum — proteger rutas de API

```php
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('pedidos', PedidoController::class);
});

// Crear token para el usuario
$token = $usuario->createToken('app-movil', ['pedidos:leer'])->plainTextToken;
```

### Artisan Command custom

```php
class SincronizarCatalogo extends Command
{
    protected $signature = 'catalogo:sincronizar {--forzar : Ignorar cache}';
    protected $description = 'Sincroniza el catalogo desde la API externa';

    public function handle(CatalogoService $servicio): int
    {
        $this->info('Iniciando sincronizacion...');
        $resultado = $servicio->sincronizar(forzar: $this->option('forzar'));
        $this->table(['Categoria', 'Productos'], $resultado->resumen());
        return Command::SUCCESS;
    }
}
```

## Anti-patrones conocidos

### Fat Controllers — NUNCA poner lógica de negocio en controllers

```php
// MAL — controller con 80 lineas de logica
public function store(Request $request) {
    $descuento = $request->codigo ? Cupon::where('codigo', $request->codigo)
        ->where('activo', true)->first()?->descuento ?? 0 : 0;
    $total = collect($request->items)->sum(fn($i) => $i['precio'] * $i['cantidad']);
    $totalConDescuento = $total * (1 - $descuento / 100);
    // ... 50 lineas mas
}

// BIEN — delegar a un service o action
public function store(StorePedidoRequest $request, CrearPedidoAction $action): JsonResponse
{
    $pedido = $action->ejecutar($request->validated());
    return new PedidoResource($pedido);
}
```

### Model Observers para lógica compleja — usar Events en su lugar

Los Observers son invisibles al lector del código. Para lógica de negocio
significativa (enviar emails, actualizar inventario) usar Events explícitamente.

### Collections::all() en tablas grandes sin paginación

```php
// MAL — carga toda la tabla en memoria
$productos = Producto::all();

// BIEN — paginar o usar lazy collection
$productos = Producto::paginate(50);
$productos = Producto::lazy(); // procesa de a chunks sin cargar todo
```

## Checklist de verificación

- [ ] Validación en Form Request, no en el controller
- [ ] Relaciones cargadas con `with()` en colecciones (sin N+1)
- [ ] Respuestas de API usando API Resources
- [ ] Operations >200ms despachadas como Jobs
- [ ] Sin interpolación de strings en queries SQL
- [ ] Autenticación con Sanctum en rutas protegidas
- [ ] `$fillable` o `$guarded` definido en cada modelo

## Referencias

- [Laravel Documentation](https://laravel.com/docs)
- [Laravel Best Practices](https://github.com/alexeymezenin/laravel-best-practices)

## Gotchas / Errores comunes no obvios

**Eloquent `with()` cargado en el controller no se aplica a queries del service**: si el controller hace `Factura::with('cliente')->find($id)` pero el service tiene su propio `Factura::find($id)` internamente, la relación `cliente` no se carga en la segunda query. Causa: `with()` es por query, no por modelo. Fix: centralizar el eager loading en el repositorio o service — no dispersarlo entre capas.

**`Job::dispatch()` en un test sin `Queue::fake()` ejecuta el job de forma síncrona**: sin `Queue::fake()`, Laravel ejecuta el job inmediatamente en el mismo proceso del test si la queue driver es `sync` (la default en `testing`). Esto puede causar efectos secundarios inesperados en los datos del test. Fix: agregar `Queue::fake()` en el `setUp()` de todos los tests que disparan jobs, y usar `Queue::assertPushed(MiJob::class)` para verificar que fue despachado.

**`FormRequest` con `authorize()` retornando `false` lanza `AuthorizationException` pero en tests lanza `403` sin mensaje descriptivo**: en tests de feature, un `authorize()` que retorna `false` devuelve 403 sin indicar cuál rule falló. Causa: la excepción de autorización no incluye el mensaje del FormRequest. Fix: usar `$this->actingAs($usuario)` con el usuario correcto en los tests, y verificar el comportamiento de `authorize()` en un test unitario del FormRequest separado.

**Eloquent `$fillable` vs `$guarded` — usar `$guarded = ['id']` en lugar de `$fillable` en modelos con muchos campos puede exponer campos no deseados al añadir nuevos**: si se añade un campo sensible al modelo sin actualizar `$fillable`, no se puede asignar masivamente (seguro). Pero con `$guarded = ['id']`, el nuevo campo sí acepta asignación masiva automáticamente. Fix: usar `$fillable` explícito en modelos con campos sensibles para que el default sea denegar asignación masiva.

---
*Skill creado con swl:crear-skill el 2026-03-31. Versión 1.0.0.*
