Basado en los temas del artículo “5 Advanced Laravel Tips to Elevate Your Development Skills” (scopes, DI, eventos, factories y scheduler), pero ampliado con ejemplos prácticos para Laravel 12 / PHP 8.3 y un enfoque de producción. Medium
1) Scopes de Eloquent de verdad útiles (y rápidos)
Los query scopes encapsulan lógica de filtrado para mantener tus controladores limpios y evitar repetir condiciones.
Ejemplo (local scopes con parámetros y búsqueda):
// app/Models/Post.php namespace App\Models; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; class Post extends Model { // published_at no null y ya pasado public function scopePublished(Builder $q): Builder { return $q->whereNotNull('published_at')->where('published_at', '<=', now()); } public function scopeFromAuthor(Builder $q, int|string $authorId): Builder { return $q->where('user_id', $authorId); } public function scopeSearch(Builder $q, ?string $term): Builder { return $q->when($term, fn ($q) => $q->where(function ($q) use ($term) { $q->where('title', 'like', "%{$term}%") ->orWhere('excerpt', 'like', "%{$term}%"); }) ); } // ejemplo de scope con default param public function scopePopular(Builder $q, int $minLikes = 50): Builder { return $q->where('likes_count', '>=', $minLikes); } }
Uso combinado y performante:
$posts = Post::query() ->with(['author:id,name']) // evita N+1 y reduce columnas ->withCount('comments') // metadatos baratos ->published() ->fromAuthor($request->user()->id) ->search($request->string('q')) ->popular(100) ->latest('published_at') ->paginate(20);
Tips rápidos
Prefiere
,withCount
ywithExists
para filtros por relación sin joins manuales.whereRelationIndiza las columnas de filtro (
,published_at
,user_id
) y las foreign keys más usadas.likes_countSi un filtro aplica “siempre”, crea un Global Scope (pero documenta cómo deshabilitarlo con
).withoutGlobalScopes()
2) Inyección de dependencias y contenedor (código limpio y testeable)
Evita acoplar clases con implementaciones concretas. Programa contra interfaces y deja que el Service Container resuelva.
Contrato y dos implementaciones:
// app/Contracts/PaymentGateway.php namespace App\Contracts; interface PaymentGateway { public function charge(int $cents, string $currency, array $meta = []): string; // returns transaction id }
// app/Services/StripeGateway.php namespace App\Services; use App\Contracts\PaymentGateway; class StripeGateway implements PaymentGateway { public function __construct(private readonly string $key) {} public function charge(int $cents, string $currency, array $meta = []): string { // Llamada real a Stripe... return 'tx_'.bin2hex(random_bytes(8)); } }
// app/Services/FakeGateway.php (para tests / sandbox) namespace App\Services; use App\Contracts\PaymentGateway; class FakeGateway implements PaymentGateway { public function charge(int $cents, string $currency, array $meta = []): string { return 'fake_tx_'.($meta['order_id'] ?? '0'); } }
Enlaza en el contenedor (AppServiceProvider):
// app/Providers/AppServiceProvider.php use App\Contracts\PaymentGateway; use App\Services\StripeGateway; public function register(): void { $this->app->bind(PaymentGateway::class, function () { return new StripeGateway( key: config('services.stripe.secret') // lee de config/env ); }); // Ejemplo de binding contextual (admin usa otro gateway): // $this->app->when(AdminCheckoutController::class) // ->needs(PaymentGateway::class) // ->give(fn () => new AnotherGateway(...)); }
Consume por constructor (auto-wire):
// app/Http/Controllers/CheckoutController.php use App\Contracts\PaymentGateway; class CheckoutController { public function __construct(private readonly PaymentGateway $gateway) {} public function __invoke(Request $request) { $tx = $this->gateway->charge( cents: (int)($request->integer('amount') * 100), currency: 'MXN', meta: ['order_id' => $request->string('order_id')] ); return response()->json(['transaction' => $tx]); } }
Testing sencillo reemplazando la implementación:
// tests/Feature/CheckoutTest.php use App\Contracts\PaymentGateway; use App\Services\FakeGateway; test('cobra con gateway fake', function () { $this->app->bind(PaymentGateway::class, fn () => new FakeGateway()); $res = $this->postJson('/checkout', ['amount' => 123, 'order_id' => 'A-1']); $res->assertOk()->assertJsonPath('transaction', 'fake_tx_A-1'); });
3) Eventos y listeners para modularizar (y escalar) tu app
Desacopla efectos colaterales con Event + Listener. Mantienes controladores mínimos y lógica extensible.
Definición del evento:
// app/Events/OrderPlaced.php namespace App\Events; use App\Models\Order; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; class OrderPlaced { use Dispatchable, SerializesModels; public function __construct(public Order $order) {} }
Listeners queueables (seguro y rápido):
// app/Listeners/SendOrderConfirmation.php namespace App\Listeners; use App\Events\OrderPlaced; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; use App\Mail\OrderConfirmation; use Mail; class SendOrderConfirmation implements ShouldQueue { use InteractsWithQueue; public int $tries = 3; public int $backoff = 10; // segundos public function handle(OrderPlaced $event): void { Mail::to($event->order->customer_email) ->send(new OrderConfirmation($event->order)); } }
Registrar mapeos:
// app/Providers/EventServiceProvider.php protected $listen = [ \App\Events\OrderPlaced::class => [ \App\Listeners\SendOrderConfirmation::class, \App\Listeners\RecalculateCustomerLifetimeValue::class, \App\Listeners\NotifyWarehouse::class, ], ];
Disparar evento en tu caso de uso:
Order::create($payload); event(new OrderPlaced($order));
Tips
Marca listeners pesados como
.ShouldQueueUsa jobs dedicados dentro del listener si necesitas retry granular.
Agrega rate limiting por usuario si el evento se dispara desde inputs.
4) Testing rápido con Model Factories, estados y relaciones
Las factories modernas (clases) permiten componer escenarios complejos en una línea.
// database/factories/OrderFactory.php namespace Database\Factories; use App\Models\Order; use Illuminate\Database\Eloquent\Factories\Factory; class OrderFactory extends Factory { protected $model = Order::class; public function definition(): array { return [ 'customer_email' => $this->faker->safeEmail(), 'total_cents' => $this->faker->numberBetween(1500, 25000), 'status' => 'pending', ]; } public function paid(): self { return $this->state(fn () => ['status' => 'paid']); } public function withItems(int $count = 3): self { return $this->has(\App\Models\OrderItem::factory()->count($count), 'items'); } }
Test con Pest/PhpUnit:
use App\Models\Order; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); it('crea una orden pagada con 3 items', function () { $order = Order::factory()->paid()->withItems(3)->create(); expect($order->status)->toBe('paid'); expect($order->items)->toHaveCount(3); });
Extras útiles
para variar valores por lote.sequence()
para enganchar lógica posterior (p.ej. recalcular totales).afterCreating()Factories para pivots (
).->hasAttached($products, ['qty' => 2])
5) Scheduler + comandos artesanales: automatiza la casa
Crea custom Artisan commands para mantenimiento y agenda tareas con el scheduler.
Comando para depurar órdenes antiguas:
// app/Console/Commands/PurgeOldOrders.php namespace App\Console\Commands; use App\Models\Order; use Illuminate\Console\Command; class PurgeOldOrders extends Command { protected $signature = 'orders:purge {--days=30} {--dry}'; protected $description = 'Elimina órdenes antiguas (soft-delete)'; public function handle(): int { $days = (int) $this->option('days'); $q = Order::where('status', 'cancelled') ->where('updated_at', '<=', now()->subDays($days)); $count = (clone $q)->count(); if ($this->option('dry')) { $this->info("Se eliminarían {$count} órdenes."); return self::SUCCESS; } $deleted = $q->delete(); $this->info("Eliminadas: {$deleted}"); return self::SUCCESS; } }
Agenda segura y sin solaparse:
// app/Console/Kernel.php use Illuminate\Console\Scheduling\Schedule; protected function schedule(Schedule $schedule): void { $schedule->command('orders:purge --days=45') ->dailyAt('02:10') ->withoutOverlapping() // evita ejecuciones concurrentes ->onOneServer() // en clúster ->environments(['production']) ->sendOutputTo(storage_path('logs/schedule.log')) ->emailOutputOnFailure('ops@tuapp.com') // pings de salud (si usas un monitor) //->pingBefore('https://hc-ping/...')->pingAfter('https://hc-ping/...') ; }
Ejecución en producción
Cron clásico:
* * * * * php /path/artisan schedule:run >> /dev/null 2>&1O proceso largo con
administrado por Supervisor/Systemd (latencia menor).php artisan schedule:work
Bonus: checklist de producción (rápido)
Eager loading y
reducido en listados grandes.select()Cache para cálculos/consultas costosas (
+ invalidación clara).Cache::remember()Métricas y trazas (Clockwork, Telescope/Sentry) para detectar cuellos de botella.
Validación estricta y DTOs/FormRequests para inputs críticos.
Rate limiting en endpoints sensibles.
Conclusión
Estos cinco frentes (scopes, DI, eventos, factories y scheduler) te dan código limpio, testeable y escalable. Empieza aplicando dos o tres hoy mismo (por ejemplo, extrae filtros repetidos a scopes y mueve efectos colaterales a listeners queueables) y notarás la diferencia en mantenibilidad y performance.