laravel-ops
Laravel framework patterns, Eloquent ORM, authentication, queues, and testing. Use for: laravel, eloquent, artisan, blade, php, sanctum, livewire, inertia, pest, phpunit, forge, vapor, queue, middleware, migration, factory, seeder.
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/laravel-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Laravel Operations
Facts verified as of 2026-07.
Authoritative reference for Laravel 11+ development: architecture decisions, Eloquent patterns, authentication strategies, queue configuration, and testing approaches.
Architecture Decision Tree
What type of application?
│
├─ Full-stack web (HTML responses)
│ ├─ Simple CRUD, small team → Monolith (Blade + Eloquent directly)
│ │ └─ Use action classes for business logic over 20 lines
│ ├─ Rich interactivity needed → Livewire (server-driven reactivity)
│ │ └─ Add Alpine.js for client-side micro-interactions
│ └─ SPA-like feel, React/Vue team → Inertia.js
│ └─ Keep server-side routing, dump client-side routing overhead
│
├─ API backend (JSON responses)
│ ├─ Single consumer (mobile/SPA) → API-only with Sanctum SPA auth
│ ├─ Multiple consumers / public → RESTful API with token auth
│ └─ Complex graph queries → Consider GraphQL (lighthouse-php/lighthouse)
│
├─ Large team / complex domain
│ ├─ Domain-driven → Modular monolith (app/Modules/{Domain}/)
│ │ ├─ Each module: Models, Actions, Events, Jobs, Http/
│ │ └─ Shared: app/Shared/ for cross-cutting concerns
│ └─ Independent deployability needed → Microservices
│ └─ Use Laravel Octane for high-throughput services
│
└─ What business logic pattern?
├─ Simple CRUD, < 20 lines → Direct Eloquent in controller
├─ Reusable operation (create order, send invoice) → Action class
│ └─ Single public handle() or execute() method
├─ Complex queries, multiple data sources → Repository pattern
│ └─ Interface + Eloquent implementation (enables swapping)
└─ Cross-cutting operations (audit, caching) → Service class
└─ Inject via constructor, bind in ServiceProvider
Action Class vs Repository vs Service
| Pattern | Use When | Example |
|---|---|---|
| Action class | Single, reusable business operation | CreateOrderAction, SendInvoiceAction |
| Repository | Abstract data access, multiple sources | OrderRepository with EloquentOrderRepository |
| Service | Orchestrate multiple actions/repos | OrderService combining payment + inventory |
| Direct Eloquent | Simple CRUD, < 5 lines in controller | User::create($data) |
Eloquent Quick Reference
Relationships
| Relationship | Method | Foreign Key Convention |
|---|---|---|
hasOne |
return $this->hasOne(Profile::class) |
profiles.user_id |
hasMany |
return $this->hasMany(Post::class) |
posts.user_id |
belongsTo |
return $this->belongsTo(User::class) |
posts.user_id |
belongsToMany |
return $this->belongsToMany(Role::class) |
role_user pivot |
hasManyThrough |
return $this->hasManyThrough(Post::class, User::class) |
Country → User → Post |
morphTo |
return $this->morphTo() |
{col}_type, {col}_id |
morphMany |
return $this->morphMany(Comment::class, 'commentable') |
Polymorphic |
morphToMany |
return $this->morphToMany(Tag::class, 'taggable') |
Polymorphic pivot |
Eager Loading
// Prevent N+1: always eager load in controllers
$posts = Post::with(['author', 'comments.author', 'tags'])->paginate(15);
// Conditional eager loading (load after retrieval)
$user->load('posts.comments');
$user->loadMissing('posts'); // only if not already loaded
// Eager load counts (no SELECT *)
$posts = Post::withCount('comments')->get();
// Constrained eager loading
$posts = Post::with(['comments' => fn($q) => $q->approved()->latest()])->get();
Query Scopes
// Local scope (reusable query constraint)
public function scopeActive(Builder $query): void
{
$query->where('status', 'active');
}
// Usage: User::active()->get()
// Dynamic scope
public function scopeOfType(Builder $query, string $type): void
{
$query->where('type', $type);
}
// Usage: User::ofType('admin')->get()
Mass Assignment
// Fillable (allowlist - preferred)
protected $fillable = ['name', 'email', 'password'];
// Guarded (denylist - use [] only if you trust all input)
protected $guarded = ['id', 'is_admin'];
// Never set guarded = [] in production code
Artisan Command Cheat Sheet
| Command | Purpose | Common Options |
|---|---|---|
make:model Post -mfs |
Model + migration + factory + seeder | -c controller, -r resource |
make:controller PostController -r |
Resource controller (7 methods) | --api skips create/edit |
make:request StorePostRequest |
Form request for validation | |
make:job ProcessPayment |
Queueable job class | --sync for sync job |
make:event OrderPlaced |
Event class | |
make:listener SendOrderConfirmation -e OrderPlaced |
Listener for event | --queued |
make:notification InvoicePaid |
Notification class | |
make:policy PostPolicy -m Post |
Policy with model | |
make:middleware EnsureUserIsAdmin |
HTTP middleware | |
make:command SendDailyReport |
Custom Artisan command | |
migrate |
Run pending migrations | --step for individual |
migrate:rollback |
Roll back last batch | --step=5 |
migrate:fresh --seed |
Drop all + re-migrate + seed | |
db:seed |
Run all seeders | --class=UserSeeder |
tinker |
REPL with app context | |
route:list |
Show all routes | --name=api filter |
route:cache |
Cache routes for production | |
config:cache |
Cache config for production | |
view:cache |
Pre-compile Blade templates | |
optimize |
Run all cache commands | optimize:clear to reset |
queue:work |
Process queue jobs | --queue=high,default |
queue:listen |
Work + auto-reload on code change | |
queue:failed |
List failed jobs | |
queue:retry all |
Retry all failed jobs | |
schedule:run |
Run due scheduled tasks | |
schedule:work |
Run scheduler every minute (dev) | |
key:generate |
Generate APP_KEY | |
test |
Run PHPUnit/Pest tests | --filter=UserTest |
test --parallel |
Run tests in parallel | --processes=4 |
vendor:publish |
Publish package assets/config | --tag=config |
Authentication Decision Tree
What do you need?
│
├─ SPA (Vue/React) + Laravel API backend
│ └─ Sanctum SPA authentication
│ ├─ Cookie-based (same domain or subdomain)
│ ├─ Csrf-cookie endpoint: GET /sanctum/csrf-cookie
│ └─ No tokens in localStorage (XSS safe)
│
├─ Mobile app or third-party API consumers
│ └─ Sanctum API tokens (Bearer tokens)
│ ├─ createToken($name, $abilities)
│ ├─ Token abilities for fine-grained control
│ └─ Token expiration with token:prune schedule
│
├─ Traditional web app (server-rendered)
│ ├─ Just need auth pages quickly → Breeze
│ │ ├─ Minimal, educational, Blade or Inertia stack
│ │ └─ Install: composer require laravel/breeze --dev
│ ├─ Need teams, 2FA, profile management → Jetstream
│ │ ├─ Livewire or Inertia stack
│ │ └─ Install: composer require laravel/jetstream
│ └─ Need headless auth (API + custom UI) → Fortify
│ ├─ Actions in app/Actions/Fortify/
│ └─ Customize: CreateNewUser, UpdateUserPassword
│
└─ Custom / enterprise
├─ LDAP/SAML → socialiteproviders/saml2
├─ OAuth social login → laravel/socialite
└─ Custom guard → Implement Guard + UserProvider contracts
Sanctum Quick Setup
// config/sanctum.php - stateful domains for SPA
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost')),
// API token creation
$token = $user->createToken('mobile-app', ['orders:read', 'orders:write']);
return ['token' => $token->plainTextToken];
// Check token ability
Route::get('/orders', function (Request $request) {
$request->user()->tokenCan('orders:read'); // bool
});
// Protect routes
Route::middleware('auth:sanctum')->group(function () {
// authenticated routes
});
Queue Decision Tree
Queue driver selection:
│
├─ Development / testing
│ └─ sync driver (executes immediately, no worker needed)
│ QUEUE_CONNECTION=sync
│
├─ Small app, no Redis available
│ └─ database driver
│ ├─ php artisan queue:table && migrate
│ ├─ Works fine for < 100 jobs/min
│ └─ QUEUE_CONNECTION=database
│
├─ Medium-high throughput, self-hosted
│ └─ Redis driver (via predis or phpredis)
│ ├─ QUEUE_CONNECTION=redis
│ ├─ Laravel Horizon for monitoring
│ └─ Supports priorities, pausing, metrics
│
└─ AWS infrastructure / massive scale
└─ SQS driver
├─ QUEUE_CONNECTION=sqs
├─ Managed, auto-scaling
└─ Use with Laravel Vapor for serverless
Job Patterns
// Basic job dispatch
ProcessPayment::dispatch($order);
ProcessPayment::dispatch($order)->onQueue('payments')->delay(now()->addMinutes(5));
// Chaining (sequential)
Bus::chain([
new ProcessPayment($order),
new SendInvoice($order),
new UpdateInventory($order),
])->dispatch();
// Batching (parallel + callback)
$batch = Bus::batch([
new ImportRow($row1),
new ImportRow($row2),
new ImportRow($row3),
])->then(fn(Batch $batch) => ImportComplete::dispatch())
->catch(fn(Batch $batch, Throwable $e) => Log::error($e))
->dispatch();
// Rate limiting (throttle to 5 per minute)
public function middleware(): array
{
return [new RateLimited('payments')];
}
// Unique jobs (prevent duplicate processing)
use Illuminate\Contracts\Queue\ShouldBeUnique;
class ProcessPayment implements ShouldQueue, ShouldBeUnique
{
public string $uniqueId => $this->order->id;
public int $uniqueFor = 3600; // seconds
}
// Retry configuration
public int $tries = 3;
public int $backoff = 60; // seconds between retries
public function retryUntil(): DateTime
{
return now()->addHours(24);
}
Task Scheduling
// routes/console.php (Laravel 11+)
Schedule::job(SendDailyReport::class)->dailyAt('08:00')->timezone('America/New_York');
Schedule::command('backup:run')->daily()->runInBackground()->emailOutputOnFailure('ops@app.com');
Schedule::call(fn() => Cache::flush())->weekly()->sundays()->at('00:00');
// Prevent overlap (long-running tasks)
Schedule::job(ProcessImport::class)->everyFiveMinutes()->withoutOverlapping();
// Run on one server only (requires Redis/database cache driver)
Schedule::job(SendNewsletters::class)->daily()->onOneServer();
Testing Quick Reference
Test Types
| Type | Class extends | Database | Purpose |
|---|---|---|---|
| Feature test | Tests\TestCase |
Yes (with trait) | HTTP endpoints, full stack |
| Unit test | PHPUnit\Framework\TestCase |
No | Pure logic, no app boot |
| Browser test | Laravel\Dusk\TestCase |
Yes | Real browser via ChromeDriver |
Database Traits
use Illuminate\Foundation\Testing\RefreshDatabase; // migrate fresh each test (slower)
use Illuminate\Foundation\Testing\DatabaseTransactions; // rollback each test (faster)
Pest Syntax (preferred in Laravel 11+)
describe('User authentication', function () {
beforeEach(function () {
$this->user = User::factory()->create();
});
it('allows login with valid credentials', function () {
$response = $this->post('/login', [
'email' => $this->user->email,
'password' => 'password',
]);
$response->assertRedirect('/dashboard');
$this->assertAuthenticatedAs($this->user);
});
it('rejects invalid credentials')->todo();
});
Common Assertions
// HTTP response
$response->assertStatus(200);
$response->assertOk(); // 200
$response->assertCreated(); // 201
$response->assertNoContent(); // 204
$response->assertUnauthorized(); // 401
$response->assertForbidden(); // 403
$response->assertNotFound(); // 404
$response->assertRedirect('/home');
// JSON responses
$response->assertJson(['status' => 'ok']);
$response->assertJsonPath('data.email', 'user@example.com');
$response->assertJsonCount(3, 'data');
$response->assertJsonStructure(['data' => ['id', 'name', 'email']]);
$response->assertJsonMissing(['password']);
// Database
$this->assertDatabaseHas('users', ['email' => 'user@example.com']);
$this->assertDatabaseMissing('users', ['email' => 'deleted@example.com']);
$this->assertDatabaseCount('posts', 5);
$this->assertSoftDeleted('posts', ['id' => $post->id]);
Common Gotchas
| Gotcha | Why | Fix |
|---|---|---|
| N+1 queries on relationships | Eloquent lazy-loads by default | Use with() eager loading; enable Model::preventLazyLoading() in AppServiceProvider during development |
| Mass assignment vulnerability | $fillable = [] accepts all |
Always define $fillable; never use $guarded = [] in production |
created_at not updating on update() |
Only updated_at auto-sets |
Use $model->touch() or timestamps = true (default) |
| Queue job fails on model serialization | Model state may change between dispatch and processing | Use SerializesModels trait; re-fetch from DB in handle() if needed |
| Timezone mismatch in scheduled tasks | Server tz != app tz | Set APP_TIMEZONE in .env; use ->timezone() on schedule entries |
| Middleware order matters | Auth middleware must run before policies | Global → route group → route. Auth before throttle check or vice versa changes 401 vs 429 |
| Route model binding skips soft-deleted records | RouteServiceProvider ignores trashed() |
Extend binding: Route::bind('post', fn($id) => Post::withTrashed()->findOrFail($id)) |
| Service container binding not auto-resolved | Interface not bound to implementation | Register in AppServiceProvider::register(): $this->app->bind(Interface::class, Implementation::class) |
| Migration foreign key order | Must create referenced table first | Run migrate:fresh to verify; use Schema::disableForeignKeyConstraints() in tests |
| CSRF protection blocks API routes | VerifyCsrfToken runs on all web routes |
Register API routes in routes/api.php (uses api middleware group without CSRF) |
env() returns null after caching |
config:cache bakes env values |
Always access env via config() helper in app code; only use env() in config/ files |
Blade @stack renders in wrong order |
@push must appear after @stack in execution |
Use @prepend for scripts that must appear first |
| Event listener not firing | Listener not registered or discovered | Check EventServiceProvider::$listen; or enable Event::discover() in Laravel 11 |
Reference Files
| File | Contents |
|---|---|
references/eloquent-queries.md |
Deep-dive: relationships, query builder, scopes, accessors, mutators, events, soft deletes, pagination, performance, collections, factories |
references/architecture.md |
Service container, providers, facades, middleware, events, notifications, jobs, scheduling, Blade components, Livewire, Inertia |
references/testing-auth.md |
PHPUnit/Pest setup, HTTP tests, database testing, fakes, Sanctum, Fortify, policies, form requests, Dusk |
See Also
sql-ops- Query optimization, indexing strategy, raw SQL patternspostgres-ops- PostgreSQL-specific features, JSON columns, full-text searchtesting-ops- General testing philosophy, TDD, CI integrationdocker-ops- Containerizing Laravel apps, Docker Compose, production setup
Key External Resources
- Laravel 11 Documentation
- Pest PHP
- Laravel Horizon - Queue monitoring
- Laravel Telescope - Local debugging and request/query monitoring
- Laravel Octane - High-performance serving
- Laravel Forge - Server management
- Laravel Vapor - Serverless deployment
Files (claude-mods)
-
assets
-
.gitkeep 0 B · in bundle
-
-
references
-
architecture.md 24.2 KB
# Laravel Architecture Reference Deep-dive reference for Laravel 11+ architecture: service container, providers, facades, middleware, events, notifications, jobs, scheduling, Blade, Livewire, and Inertia. --- ## Service Container The container resolves class dependencies automatically via reflection. ### Binding Types ```php // AppServiceProvider::register() // Bind (new instance each resolution) $this->app->bind(PaymentGateway::class, StripeGateway::class); $this->app->bind(PaymentGateway::class, function ($app) { return new StripeGateway($app->make(HttpClient::class), config('stripe.key')); }); // Singleton (same instance every resolution) $this->app->singleton(AnalyticsService::class, function ($app) { return new AnalyticsService($app->make(Logger::class)); }); // Instance (bind a pre-existing object) $this->app->instance(Config::class, new Config(['debug' => true])); // Scoped (singleton per request lifecycle - useful with Octane) $this->app->scoped(RequestContext::class, function ($app) { return new RequestContext($app->make(Request::class)); }); ``` ### Contextual Binding ```php // Give different implementations to different classes $this->app->when(PhotoController::class) ->needs(Filesystem::class) ->give(fn() => Storage::disk('photos')); $this->app->when(VideoController::class) ->needs(Filesystem::class) ->give(fn() => Storage::disk('videos')); // Bind tagged implementations $this->app->bind(CsvReport::class, fn() => new CsvReport()); $this->app->bind(PdfReport::class, fn() => new PdfReport()); $this->app->tag([CsvReport::class, PdfReport::class], 'reports'); $reports = $this->app->tagged('reports'); // array of resolved instances ``` ### Auto-Resolution and Method Injection ```php // Constructor injection (auto-resolved) class OrderService { public function __construct( private readonly PaymentGateway $payment, private readonly InventoryRepository $inventory, private readonly EventDispatcher $events, ) {} } // Call with method injection $result = app()->call([OrderService::class, 'process'], ['orderId' => 123]); // Resolve with makeWith (pass primitives) $service = app()->makeWith(ReportService::class, ['format' => 'pdf']); ``` --- ## Service Providers ### Structure ```php class AppServiceProvider extends ServiceProvider { // Bindings array - simple alias public array $bindings = [ OrderRepositoryInterface::class => EloquentOrderRepository::class, ]; // Singletons array public array $singletons = [ CurrencyConverter::class => CurrencyConverter::class, ]; // register(): bind into container (no other services available yet) public function register(): void { $this->app->bind(PaymentGateway::class, fn($app) => new StripeGateway( config('services.stripe.key') )); } // boot(): everything is registered, safe to use facades and other services public function boot(): void { Model::preventLazyLoading(! $this->app->isProduction()); Blade::directive('money', fn($amount) => "<?php echo money_format({$amount}); ?>"); Post::observe(PostObserver::class); Validator::extend('phone', [PhoneValidator::class, 'validate']); } } ``` ### Deferred Providers ```php // Only loaded when the binding is actually requested class ReportServiceProvider extends ServiceProvider implements DeferrableProvider { public function register(): void { $this->app->singleton(ReportGenerator::class, fn() => new ReportGenerator()); } public function provides(): array { return [ReportGenerator::class]; // what this provider resolves } } ``` ### Package Service Providers ```php class PackageServiceProvider extends ServiceProvider { public function register(): void { $this->mergeConfigFrom(__DIR__.'/../config/package.php', 'package'); } public function boot(): void { // Publish config $this->publishes([ __DIR__.'/../config/package.php' => config_path('package.php'), ], 'config'); // Publish migrations $this->publishes([ __DIR__.'/../database/migrations' => database_path('migrations'), ], 'migrations'); // Load migrations without publishing $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); // Load routes $this->loadRoutesFrom(__DIR__.'/../routes/web.php'); // Load views (with namespace prefix) $this->loadViewsFrom(__DIR__.'/../resources/views', 'package'); } } ``` --- ## Facades Facades provide a static interface to services in the container. ```php // How facades work internally Cache::get('key'); // resolves to: app('cache')->get('key') // Real-time facades (prefix with Facades\) use Facades\App\Services\PaymentGateway; PaymentGateway::charge($amount); // automatically resolved from container // All standard facades use Illuminate\Support\Facades\{ App, Artisan, Auth, Blade, Bus, Cache, Config, Cookie, Crypt, DB, Event, File, Gate, Hash, Http, Log, Mail, Notification, Queue, Redirect, Request, Response, Route, Schema, Session, Storage, URL, Validator, View }; ``` ### Testing with Facade Fakes ```php // In test setup - swap real implementation with fake Event::fake(); Mail::fake(); Notification::fake(); Queue::fake(); Bus::fake(); Storage::fake('s3'); Http::fake(['api.stripe.com/*' => Http::response(['id' => 'ch_123'], 200)]); // Then assert interactions Event::assertDispatched(OrderPlaced::class, fn($e) => $e->order->id === $orderId); Event::assertNotDispatched(OrderCancelled::class); Mail::assertSent(InvoiceMail::class, fn($mail) => $mail->hasTo('user@example.com')); Notification::assertSentTo($user, InvoicePaidNotification::class); Queue::assertPushed(ProcessPayment::class, fn($job) => $job->order->id === $orderId); Queue::assertPushedOn('high-priority', ProcessPayment::class); ``` --- ## Middleware ### Defining Middleware ```php // php artisan make:middleware EnsureUserIsSubscribed class EnsureUserIsSubscribed { public function handle(Request $request, Closure $next): Response { if (! $request->user()?->subscribed()) { return redirect('/billing')->with('error', 'Subscription required.'); } return $next($request); } } // Middleware with parameters class EnsureRole { public function handle(Request $request, Closure $next, string ...$roles): Response { if (! $request->user()->hasAnyRole($roles)) { abort(403); } return $next($request); } } // Route: Route::middleware('role:admin,editor')->group(...) ``` ### Registering Middleware (Laravel 11+) ```php // bootstrap/app.php ->withMiddleware(function (Middleware $middleware) { // Global middleware $middleware->append(LogHttpRequests::class); $middleware->prepend(TrustProxies::class); // Named middleware aliases $middleware->alias([ 'subscribed' => EnsureUserIsSubscribed::class, 'role' => EnsureRole::class, ]); // Middleware groups $middleware->group('api', [ ThrottleRequests::class.':api', SubstituteBindings::class, ]); // Exclude from global middleware $middleware->except([VerifyCsrfToken::class], ['/webhooks/*']); }) ``` ### Terminable Middleware ```php // Runs AFTER response is sent (for cleanup, logging) class LogResponseTime implements TerminableMiddleware { private float $startTime; public function handle(Request $request, Closure $next): Response { $this->startTime = microtime(true); return $next($request); } public function terminate(Request $request, Response $response): void { $duration = microtime(true) - $this->startTime; Log::channel('performance')->info('Request completed', [ 'url' => $request->fullUrl(), 'duration' => round($duration * 1000, 2) . 'ms', 'status' => $response->getStatusCode(), ]); } } ``` ### Rate Limiting ```php // AppServiceProvider::boot() or RouteServiceProvider RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); }); RateLimiter::for('uploads', function (Request $request) { return [ Limit::perMinute(10)->by($request->user()->id), // per user Limit::perDay(100)->by($request->user()->id), // daily cap ]; }); // Route-level: Route::middleware('throttle:api')->group(...) ``` --- ## Events and Listeners ### Event Classes ```php // php artisan make:event OrderPlaced class OrderPlaced { use Dispatchable, InteractsWithSockets, SerializesModels; public function __construct( public readonly Order $order, public readonly User $customer, ) {} // Broadcast over WebSockets (optional) public function broadcastOn(): array { return [new PrivateChannel("orders.{$this->order->id}")]; } } ``` ### Listener Classes ```php // php artisan make:listener SendOrderConfirmation --event=OrderPlaced class SendOrderConfirmation implements ShouldQueue { use InteractsWithQueue; public string $queue = 'notifications'; public int $tries = 3; public function handle(OrderPlaced $event): void { Mail::to($event->customer)->send(new OrderConfirmationMail($event->order)); } public function failed(OrderPlaced $event, Throwable $exception): void { Log::error('Failed to send order confirmation', ['order_id' => $event->order->id]); } } ``` ### Event Discovery (Laravel 11+) ```php // bootstrap/app.php - auto-discover listeners in app/Listeners ->withEvents(function (Dispatcher $events) { $events->listen(OrderPlaced::class, SendOrderConfirmation::class); $events->listen(OrderPlaced::class, UpdateInventory::class); // Or enable auto-discovery: // $events->discover(app_path('Listeners')); }) // Dispatch OrderPlaced::dispatch($order, $user); event(new OrderPlaced($order, $user)); // equivalent ``` --- ## Notifications ### Notification Class ```php // php artisan make:notification InvoicePaid class InvoicePaid extends Notification implements ShouldQueue { public function __construct(private readonly Invoice $invoice) {} // Which channels to send on public function via(object $notifiable): array { return $notifiable->prefers_sms ? ['mail', 'vonage'] : ['mail', 'database']; } // Email channel public function toMail(object $notifiable): MailMessage { return (new MailMessage) ->subject("Invoice #{$this->invoice->number} paid") ->greeting("Hello {$notifiable->name},") ->line("Your invoice of {$this->invoice->amount_formatted} has been paid.") ->action('View Invoice', route('invoices.show', $this->invoice)) ->line('Thank you for your business!'); } // Database channel public function toDatabase(object $notifiable): array { return [ 'invoice_id' => $this->invoice->id, 'amount' => $this->invoice->amount, 'paid_at' => now()->toISOString(), ]; } // Vonage (SMS) channel public function toVonage(object $notifiable): VonageMessage { return (new VonageMessage) ->content("Invoice #{$this->invoice->number} paid. Amount: {$this->invoice->amount_formatted}"); } // Slack channel (via laravel/slack-notification-channel) public function toSlack(object $notifiable): SlackMessage { return (new SlackMessage) ->success() ->content("Invoice paid: #{$this->invoice->number}"); } } // Sending $user->notify(new InvoicePaid($invoice)); // via model Notification::send($users, new InvoicePaid($invoice)); // to collection Notification::route('mail', 'ops@app.com') // on-demand ->notify(new InvoicePaid($invoice)); // Database notifications $user->unreadNotifications; $user->notifications()->markAsRead(); ``` --- ## Jobs and Queues ### Job Class Structure ```php // php artisan make:job ProcessPayment class ProcessPayment implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 3; public int $timeout = 90; public int $backoff = 60; // seconds between retries public bool $deleteWhenMissingModels = true; public function __construct( private readonly Order $order, private readonly string $paymentMethodId, ) {} public function handle(PaymentGateway $gateway): void { // Re-fetch model (may have changed since dispatch) $order = Order::find($this->order->id); $charge = $gateway->charge($order->total, $this->paymentMethodId); $order->update(['payment_id' => $charge->id, 'status' => 'paid']); OrderPaid::dispatch($order); } // Exponential backoff per attempt public function backoff(): array { return [30, 60, 120]; // wait 30s, 60s, 120s between retries } // Called when all retries exhausted public function failed(Throwable $exception): void { $this->order->update(['status' => 'payment_failed']); Log::error('Payment failed', ['order_id' => $this->order->id, 'error' => $exception->getMessage()]); } // Middleware on job public function middleware(): array { return [ new RateLimited('payments'), new WithoutOverlapping($this->order->id), // prevent duplicate processing ]; } } // Dispatch patterns ProcessPayment::dispatch($order, $paymentMethodId); ProcessPayment::dispatch($order, $paymentMethodId)->onQueue('payments'); ProcessPayment::dispatch($order, $paymentMethodId)->delay(now()->addSeconds(30)); ProcessPayment::dispatchSync($order, $paymentMethodId); // synchronous (bypasses queue) ProcessPayment::dispatchIf($order->requiresPayment(), $order, $paymentMethodId); ProcessPayment::dispatchUnless($order->isFree(), $order, $paymentMethodId); ``` ### Batches and Chains ```php // Chain (sequential - each waits for previous to complete) Bus::chain([ new ValidateOrder($order), new ProcessPayment($order, $method), new SendConfirmation($order), ])->onQueue('orders') ->catch(fn(Throwable $e) => $order->markAsFailed($e->getMessage())) ->dispatch(); // Batch (parallel - all run concurrently) $batch = Bus::batch( $rows->map(fn($row) => new ImportRow($row))->all() )->then(function (Batch $batch) { ImportComplete::dispatch($batch->id); })->catch(function (Batch $batch, Throwable $e) { Log::error('Batch failed', ['id' => $batch->id]); })->finally(function (Batch $batch) { // always runs })->name('CSV Import') ->allowFailures() // don't cancel on single failure ->onQueue('imports') ->dispatch(); // Monitor batch $batch = Bus::findBatch($batchId); $batch->totalJobs; // int $batch->processedJobs(); // int $batch->failedJobs; // int $batch->progress(); // 0-100 $batch->finished(); // bool ``` --- ## Task Scheduling ```php // routes/console.php (Laravel 11+) use Illuminate\Support\Facades\Schedule; // Frequency methods Schedule::job(GenerateSitemap::class)->daily(); Schedule::job(SendNewsletters::class)->weekdays()->at('08:00'); Schedule::command('reports:monthly')->monthlyOn(1, '00:30'); Schedule::command('cache:prune')->everyFiveMinutes()->withoutOverlapping(10); // lock for 10 min max Schedule::call(fn() => DB::table('logs')->where('created_at', '<', now()->subDays(90))->delete()) ->weekly()->sundays(); // Output and notification Schedule::command('backup:run') ->daily() ->runInBackground() ->appendOutputTo(storage_path('logs/backup.log')) ->emailOutputOnFailure('ops@app.com') ->pingOnSuccess(env('HEALTHCHECK_URL')); // Run on one server (distributed lock via cache) Schedule::job(SendDailyDigest::class)->daily()->onOneServer(); // Environment constraints Schedule::command('sync:users')->hourly()->environments(['production']); // Maintenance mode bypass Schedule::job(HeartbeatCheck::class)->everyMinute()->evenInMaintenanceMode(); // Chained callbacks Schedule::call(function () { // ... })->before(fn() => Log::info('Starting')) ->after(fn() => Log::info('Complete')); ``` --- ## Blade Components ### Anonymous Components ```blade {{-- resources/views/components/alert.blade.php --}} @props(['type' => 'info', 'dismissible' => false]) <div class="alert alert-{{ $type }} {{ $dismissible ? 'alert-dismissible' : '' }}"> {{ $slot }} @if($dismissible) <button type="button" class="btn-close" data-bs-dismiss="alert"></button> @endif </div> {{-- Usage --}} <x-alert type="danger" dismissible> Something went wrong. </x-alert> ``` ### Named Slots ```blade {{-- resources/views/components/modal.blade.php --}} @props(['id', 'title']) <div id="{{ $id }}" class="modal"> <div class="modal-header"> <h5>{{ $title }}</h5> </div> <div class="modal-body"> {{ $slot }} </div> <div class="modal-footer"> {{ $footer ?? '' }} </div> </div> {{-- Usage --}} <x-modal id="confirm-delete" title="Confirm Delete"> Are you sure you want to delete this item? <x-slot:footer> <button>Cancel</button> <button class="btn-danger">Delete</button> </x-slot:footer> </x-modal> ``` ### Class-Based Components ```php // php artisan make:component UserCard class UserCard extends Component { public readonly string $initials; public function __construct( public readonly User $user, public bool $showEmail = false, ) { $this->initials = strtoupper( substr($user->first_name, 0, 1) . substr($user->last_name, 0, 1) ); } public function render(): View { return view('components.user-card'); } } {{-- resources/views/components/user-card.blade.php --}} <div class="user-card"> <div class="avatar">{{ $initials }}</div> <h3>{{ $user->name }}</h3> @if($showEmail) <p>{{ $user->email }}</p> @endif </div> ``` ### Stacks and Sections ```blade {{-- layout.blade.php --}} <html> <head> @stack('styles') {{-- filled by child views --}} </head> <body> @yield('content') @stack('scripts') </body> </html> {{-- child.blade.php --}} @extends('layout') @push('styles') <link rel="stylesheet" href="/css/dashboard.css"> @endpush @section('content') <h1>Dashboard</h1> @endsection @push('scripts') <script src="/js/dashboard.js"></script> @endpush ``` --- ## Livewire Integration Livewire 3 handles server-side state with automatic DOM diffing. ### Component Structure ```php // php artisan make:livewire SearchUsers use Livewire\Attributes\{Computed, Url}; use Livewire\Component; class SearchUsers extends Component { #[Url] // syncs to query string public string $search = ''; public string $sortBy = 'name'; public bool $showModal = false; // Runs when $search changes (debounced in view) public function updatedSearch(): void { $this->resetPage(); } // Computed property (cached per render) #[Computed] public function users(): LengthAwarePaginator { return User::where('name', 'like', "%{$this->search}%") ->orderBy($this->sortBy) ->paginate(10); } public function deleteUser(int $userId): void { $this->authorize('delete', User::find($userId)); User::destroy($userId); $this->dispatch('user-deleted'); // JS event } public function render(): View { return view('livewire.search-users'); } } ``` ```blade {{-- resources/views/livewire/search-users.blade.php --}} <div> <input wire:model.live.debounce.300ms="search" placeholder="Search..."> <select wire:model.live="sortBy"> <option value="name">Name</option> <option value="created_at">Newest</option> </select> @foreach($this->users as $user) <div wire:key="{{ $user->id }}"> {{ $user->name }} <button wire:click="deleteUser({{ $user->id }})" wire:confirm="Are you sure?"> Delete </button> </div> @endforeach {{ $this->users->links() }} {{-- Lazy loading --}} <livewire:heavy-chart lazy /> </div> ``` ### Livewire File Uploads ```php use Livewire\WithFileUploads; class UploadAvatar extends Component { use WithFileUploads; #[Validate('image|max:1024')] public $photo; public function save(): void { $path = $this->photo->store('avatars', 's3'); auth()->user()->update(['avatar' => $path]); } } ``` --- ## Inertia.js Server-side routing + client-side rendering without a separate API. ### Laravel Side ```php // Controller returns Inertia response class PostController extends Controller { public function index(): Response { return Inertia::render('Posts/Index', [ 'posts' => PostResource::collection(Post::paginate(15)), 'filters' => request()->only(['search', 'status']), ]); } // Lazy-loaded props (only sent when explicitly requested) public function show(Post $post): Response { return Inertia::render('Posts/Show', [ 'post' => PostResource::make($post), 'comments' => Inertia::lazy(fn() => CommentResource::collection($post->comments()->paginate(20))), ]); } // Redirect after form submission public function store(StorePostRequest $request): RedirectResponse { $post = Post::create($request->validated() + ['user_id' => auth()->id()]); return redirect()->route('posts.show', $post)->with('success', 'Post created.'); } } // Shared data (available on every page) // HandleInertiaRequests middleware public function share(Request $request): array { return [ ...parent::share($request), 'auth' => [ 'user' => $request->user()?->only('id', 'name', 'email'), ], 'flash' => [ 'success' => $request->session()->get('success'), 'error' => $request->session()->get('error'), ], ]; } ``` ### Vue Side (Inertia + Vue 3) ```vue <!-- resources/js/Pages/Posts/Index.vue --> <script setup> import { ref } from 'vue' import { router, useForm, usePage } from '@inertiajs/vue3' const props = defineProps({ posts: Object, filters: Object, }) const page = usePage() const auth = page.props.auth // shared data // Form helper const form = useForm({ title: '', body: '', }) function submit() { form.post('/posts', { onSuccess: () => form.reset(), }) } // Partial reloads (only refresh 'posts' prop) function search(query) { router.get('/posts', { search: query }, { preserveState: true, only: ['posts'], }) } </script> <template> <div> <div v-for="post in posts.data" :key="post.id"> <Link :href="`/posts/${post.id}`">{{ post.title }}</Link> </div> <!-- Inertia pagination links --> <Pagination :links="posts.links" /> </div> </template> ``` --- ## Blade Directives and Helpers ### Authorization Directives ```blade @auth <a href="/dashboard">Dashboard</a> @endauth @guest <a href="/login">Login</a> @endguest @can('update', $post) <a href="{{ route('posts.edit', $post) }}">Edit</a> @endcan @cannot('delete', $post) <p>You cannot delete this post.</p> @endcannot @role('admin') {{-- if using spatie/laravel-permission --}} <a href="/admin">Admin Panel</a> @endrole ``` ### Looping Directives ```blade @forelse($posts as $post) <article>{{ $post->title }}</article> @empty <p>No posts found.</p> @endforelse {{-- Loop variable --}} @foreach($items as $item) @if($loop->first) <ul> @endif <li class="{{ $loop->even ? 'even' : 'odd' }}"> {{ $loop->iteration }}. {{ $item->name }} </li> @if($loop->last) </ul> @endif @endforeach ``` ### Custom Directives ```php // AppServiceProvider::boot() Blade::directive('currency', function ($expression) { return "<?php echo '$' . number_format({$expression}, 2); ?>"; }); Blade::if('env', function (string $environment) { return app()->environment($environment); }); // Usage: @env('production') ... @endenv ``` -
eloquent-queries.md 20.8 KB
# Eloquent Queries Reference Deep-dive reference for Eloquent ORM: relationships, query builder, scopes, accessors, mutators, events, soft deletes, pagination, performance, collections, and factories. --- ## Relationships ### hasOne ```php // User hasOne Profile class User extends Model { public function profile(): HasOne { return $this->hasOne(Profile::class); // Convention: profiles.user_id // Custom: $this->hasOne(Profile::class, 'foreign_key', 'local_key') } } // Usage $profile = $user->profile; // lazy load $user = User::with('profile')->find(1); // eager load $user->profile()->create(['bio' => '...']); // create via relationship ``` ### hasMany ```php class User extends Model { public function posts(): HasMany { return $this->hasMany(Post::class); } } // Usage $posts = $user->posts; // Collection $posts = $user->posts()->published()->get(); // chained query $user->posts()->createMany([ ['title' => 'First'], ['title' => 'Second'], ]); ``` ### belongsTo ```php class Post extends Model { public function author(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); // explicit FK } } // Avoid null when accessing author before saving $post->author()->associate($user); // sets user_id $post->save(); // Dissociate (set FK to null) $post->author()->dissociate(); $post->save(); ``` ### belongsToMany (many-to-many with pivot) ```php class User extends Model { public function roles(): BelongsToMany { return $this->belongsToMany(Role::class) ->withPivot('assigned_at', 'assigned_by') ->withTimestamps() ->using(RoleUser::class); // custom pivot model } } // Pivot model with extra attributes class RoleUser extends Pivot { protected $casts = [ 'assigned_at' => 'datetime', ]; } // Attach / detach / sync $user->roles()->attach($roleId, ['assigned_by' => auth()->id()]); $user->roles()->detach($roleId); $user->roles()->sync([1, 2, 3]); // replaces all $user->roles()->syncWithoutDetaching([4, 5]); // additive only $user->roles()->toggle([1, 2]); // attach if not, detach if yes // Querying pivot $user->roles()->wherePivot('assigned_by', $userId)->get(); // Access pivot in result foreach ($user->roles as $role) { echo $role->pivot->assigned_at; } ``` ### hasManyThrough ```php // Country → User → Post (access posts through users) class Country extends Model { public function posts(): HasManyThrough { return $this->hasManyThrough( Post::class, // final model User::class, // intermediate model 'country_id', // FK on users table 'user_id', // FK on posts table 'id', // local key on countries 'id' // local key on users ); } } ``` ### hasOneThrough ```php // Mechanic → Car → CarOwner (through single intermediary) class Mechanic extends Model { public function carOwner(): HasOneThrough { return $this->hasOneThrough(Owner::class, Car::class); } } ``` ### Polymorphic: morphTo / morphMany ```php // Comment can belong to Post or Video class Comment extends Model { public function commentable(): MorphTo { return $this->morphTo(); // uses commentable_type + commentable_id } } class Post extends Model { public function comments(): MorphMany { return $this->morphMany(Comment::class, 'commentable'); } } class Video extends Model { public function comments(): MorphMany { return $this->morphMany(Comment::class, 'commentable'); } } // Usage $post->comments()->create(['body' => 'Great post!']); $comment->commentable; // returns Post or Video instance // Morph map (cleaner DB values) // AppServiceProvider::boot() Relation::morphMap([ 'post' => Post::class, 'video' => Video::class, ]); ``` ### morphToMany (polymorphic many-to-many) ```php // Post and Video can have many Tags class Post extends Model { public function tags(): MorphToMany { return $this->morphToMany(Tag::class, 'taggable'); // pivot: taggables (taggable_id, taggable_type, tag_id) } } class Tag extends Model { public function posts(): MorphedByMany { return $this->morphedByMany(Post::class, 'taggable'); } } ``` --- ## Query Builder ### Basic Constraints ```php // Where clauses User::where('status', 'active') ->where('age', '>=', 18) ->orWhere('is_admin', true) ->get(); // whereIn / whereNotIn Post::whereIn('status', ['published', 'featured'])->get(); Post::whereNotIn('user_id', [1, 2, 3])->get(); // whereNull / whereNotNull User::whereNull('deleted_at')->get(); User::whereNotNull('email_verified_at')->get(); // whereBetween Order::whereBetween('total', [100, 500])->get(); // whereDate / whereYear / whereMonth / whereDay Post::whereDate('created_at', '2024-01-15')->get(); Post::whereYear('created_at', 2024)->get(); // whereColumn (compare two columns) Order::whereColumn('shipped_at', '>', 'ordered_at')->get(); ``` ### Relationship Constraints ```php // whereHas: filter models with related models matching condition Post::whereHas('comments', function (Builder $query) { $query->where('approved', true); })->get(); // whereDoesntHave Post::whereDoesntHave('comments')->get(); // posts with no comments // withWhereHas: eager load + constrain simultaneously Post::withWhereHas('comments', fn($q) => $q->approved())->get(); // whereHas with count Post::whereHas('comments', fn($q) => $q, '>=', 5)->get(); // at least 5 comments ``` ### Subqueries ```php // Select subquery $users = User::addSelect([ 'last_login_at' => Login::select('created_at') ->whereColumn('user_id', 'users.id') ->latest() ->limit(1), ])->get(); // orderBy subquery $users = User::orderByDesc( Login::select('created_at') ->whereColumn('user_id', 'users.id') ->latest() ->limit(1) )->get(); // From subquery $orders = DB::table(function (Builder $query) { $query->from('orders')->where('status', 'shipped'); }, 'shipped_orders')->get(); ``` ### Raw Expressions ```php // selectRaw User::selectRaw('COUNT(*) as total, DATE(created_at) as date') ->groupByRaw('DATE(created_at)') ->get(); // whereRaw User::whereRaw('LOWER(email) = ?', [strtolower($email)])->first(); // orderByRaw Post::orderByRaw('FIELD(status, "featured", "published", "draft")')->get(); // havingRaw User::selectRaw('country, COUNT(*) as total') ->groupBy('country') ->havingRaw('COUNT(*) > ?', [100]) ->get(); ``` --- ## Query Scopes ### Local Scopes ```php class Post extends Model { // Constraint scope public function scopePublished(Builder $query): void { $query->where('status', 'published') ->whereNotNull('published_at'); } // Dynamic scope with parameter public function scopeByStatus(Builder $query, string $status): void { $query->where('status', $status); } // Scope with optional parameter public function scopeRecent(Builder $query, int $days = 7): void { $query->where('created_at', '>=', now()->subDays($days)); } } // Usage (chaining scopes) Post::published()->recent(30)->orderByDesc('published_at')->paginate(15); Post::byStatus('draft')->get(); ``` ### Global Scopes ```php // Define scope class class ActiveScope implements Scope { public function apply(Builder $builder, Model $model): void { $builder->where('active', true); } } // Apply globally (in model boot or via attribute in Laravel 11+) class User extends Model { protected static function booted(): void { static::addGlobalScope(new ActiveScope()); // Or anonymous: static::addGlobalScope('active', fn(Builder $b) => $b->where('active', true)); } } // Removing global scope for specific query User::withoutGlobalScope(ActiveScope::class)->get(); User::withoutGlobalScope('active')->get(); User::withoutGlobalScopes()->get(); // remove all ``` --- ## Accessors and Mutators (Laravel 11+ Attribute Class) ```php use Illuminate\Database\Eloquent\Casts\Attribute; class User extends Model { // Accessor only protected function fullName(): Attribute { return Attribute::make( get: fn() => "{$this->first_name} {$this->last_name}", ); } // Mutator only protected function password(): Attribute { return Attribute::make( set: fn(string $value) => bcrypt($value), ); } // Accessor + Mutator protected function name(): Attribute { return Attribute::make( get: fn(string $value) => ucfirst($value), set: fn(string $value) => strtolower($value), )->withoutObjectCaching(); // recompute each access } } // Usage $user->full_name; // "John Doe" $user->password = 'secret'; // automatically hashed ``` ### Built-in Casts ```php protected $casts = [ 'is_admin' => 'boolean', 'score' => 'float', 'metadata' => 'array', // JSON column ↔ array 'preferences' => 'collection', // JSON ↔ Collection 'settings' => AsArrayObject::class, // JSON ↔ ArrayObject (mutable) 'options' => AsCollection::class, // JSON ↔ Collection (mutable) 'secret' => 'encrypted', // transparent encryption 'secret_array' => 'encrypted:array', // encrypted JSON 'birthday' => 'date', // Carbon without time 'published_at' => 'datetime', // Carbon with time 'status' => PostStatus::class, // PHP 8.1 enum ]; ``` ### Enum Casting (PHP 8.1+) ```php enum PostStatus: string { case Draft = 'draft'; case Published = 'published'; case Archived = 'archived'; } class Post extends Model { protected $casts = [ 'status' => PostStatus::class, ]; } // Usage $post->status = PostStatus::Published; // or 'published' $post->status->label(); // if you add methods to enum Post::where('status', PostStatus::Published)->get(); ``` --- ## Eloquent Events ### Model Lifecycle Events | Event | Fires When | |-------|-----------| | `creating` | Before INSERT (can cancel with false) | | `created` | After INSERT | | `updating` | Before UPDATE (can cancel with false) | | `updated` | After UPDATE | | `saving` | Before INSERT or UPDATE | | `saved` | After INSERT or UPDATE | | `deleting` | Before DELETE (can cancel with false) | | `deleted` | After DELETE | | `restoring` | Before restore (soft delete) | | `restored` | After restore | | `retrieved` | After SELECT (heavy use discouraged) | ### Registering Listeners ```php // Option 1: $dispatchesEvents on model class Post extends Model { protected $dispatchesEvents = [ 'created' => PostCreated::class, 'deleted' => PostDeleted::class, ]; } // Option 2: boot() method (for closures) class Post extends Model { protected static function booted(): void { static::creating(function (Post $post) { $post->slug = Str::slug($post->title); }); static::deleting(function (Post $post) { $post->comments()->delete(); // cascade via Eloquent }); } } ``` ### Observer Classes ```php // php artisan make:observer PostObserver --model=Post class PostObserver { public function creating(Post $post): void { $post->slug = Str::slug($post->title); $post->user_id ??= auth()->id(); } public function created(Post $post): void { Cache::tags('posts')->flush(); } public function updated(Post $post): void { Cache::tags('posts')->flush(); } public function deleted(Post $post): void { $post->comments()->delete(); } } // Register in AppServiceProvider::boot() Post::observe(PostObserver::class); // Silence observer for bulk operations Post::withoutObservers(function () { Post::query()->update(['featured' => false]); }); ``` --- ## Soft Deletes ```php use Illuminate\Database\Eloquent\SoftDeletes; class Post extends Model { use SoftDeletes; // adds deleted_at column } // Migration Schema::table('posts', function (Blueprint $table) { $table->softDeletes(); // nullable deleted_at timestamp }); // Usage $post->delete(); // sets deleted_at (soft delete) $post->forceDelete(); // permanent DELETE // Querying Post::all(); // excludes soft-deleted (default) Post::withTrashed()->get(); // includes soft-deleted Post::onlyTrashed()->get(); // only soft-deleted // Restore Post::withTrashed()->find($id)->restore(); Post::withTrashed()->where('user_id', $userId)->restore(); // Check state $post->trashed(); // bool // Route model binding includes soft-deleted Route::get('/posts/{post}', [PostController::class, 'show']) ->withTrashed(); ``` --- ## Pagination | Method | Returns | Use When | |--------|---------|----------| | `paginate(15)` | `LengthAwarePaginator` | Need total count and last page | | `simplePaginate(15)` | `Paginator` | Large datasets, just next/prev needed | | `cursorPaginate(15)` | `CursorPaginator` | Huge datasets, consistent performance | ```php // Standard pagination (requires COUNT query) $posts = Post::published()->paginate(15); // Blade: {{ $posts->links() }} // Simple pagination (no COUNT, just LIMIT+1) $posts = Post::published()->simplePaginate(15); // Cursor pagination (keyset pagination - best for infinite scroll) $posts = Post::orderBy('id')->cursorPaginate(15); // URL: /posts?cursor=eyJpZCI6MTAwfQ // JSON API response return PostResource::collection($posts); // preserves pagination meta // Manual pagination $total = Post::count(); $posts = Post::skip($offset)->take($perPage)->get(); $paginator = new LengthAwarePaginator($posts, $total, $perPage, $currentPage); ``` --- ## Performance: Chunking and Lazy Loading ### When to Use Each | Method | Memory | Speed | Use When | |--------|--------|-------|----------| | `get()` | All records | Fast | < 10k records | | `chunk(1000)` | Chunk size | Moderate | Large datasets, mutations | | `chunkById(1000)` | Chunk size | More stable | Large datasets (avoids offset drift) | | `lazy()` | Low (generator) | Fast | Read-only iteration | | `cursor()` | Very low | Fastest | Streaming large result sets | ```php // chunk - runs separate queries per chunk Post::where('status', 'draft')->chunk(500, function (Collection $posts) { foreach ($posts as $post) { $post->update(['status' => 'published']); } }); // chunkById - stable cursor-based chunking (avoids missing rows when deleting) Post::orderBy('id')->chunkById(500, function (Collection $posts) { $posts->each->delete(); }); // lazy - PHP generator, single query with cursor foreach (Post::lazy(500) as $post) { ProcessPost::dispatch($post); } // cursor - yields one model at a time, minimal memory foreach (Post::cursor() as $post) { echo $post->title . PHP_EOL; } ``` ### Query Logging and Debugging ```php // Log all queries (AppServiceProvider::boot) DB::listen(function (QueryExecuted $query) { Log::channel('queries')->info($query->sql, [ 'bindings' => $query->bindings, 'time' => $query->time, ]); }); // Explain a query $posts = Post::with('comments')->where('status', 'published'); dd($posts->explain()); // EXPLAIN output // Count queries executed (testing) DB::enableQueryLog(); // ... run code ... $queries = DB::getQueryLog(); expect($queries)->toHaveCount(2); // assert no N+1 DB::disableQueryLog(); // Prevent lazy loading in development Model::preventLazyLoading(! app()->isProduction()); ``` --- ## Collections Eloquent returns `Illuminate\Database\Eloquent\Collection` (extends base Collection). ```php $users = User::all(); // Transformation $names = $users->pluck('name'); // Collection of names $names = $users->pluck('name', 'id'); // ['id' => 'name'] keyed $active = $users->filter(fn($u) => $u->is_active); $admins = $users->where('role', 'admin'); $mapped = $users->map(fn($u) => ['id' => $u->id, 'email' => $u->email]); $grouped = $users->groupBy('country'); // keyed Collection of Collections $sorted = $users->sortBy('name'); $sorted = $users->sortByDesc(fn($u) => $u->posts_count); // Aggregation $total = $users->sum('balance'); $avg = $users->avg('score'); $max = $users->max('score'); $count = $users->count(); $first = $users->first(fn($u) => $u->is_admin); // Unique / diff / intersect $unique = $users->unique('email'); $diff = $users->diff($otherUsers); // Collection to array/JSON $array = $users->toArray(); $json = $users->toJson(); // Reduce $total = $users->reduce(fn($carry, $user) => $carry + $user->balance, 0); // Eloquent-specific collection methods $users->find(1); // find by PK $users->load('posts'); // eager load on collection $users->modelKeys(); // array of primary keys $users->contains($user); // check membership $users->diff($otherUsers); // by PK comparison // Lazy collections (memory efficient) User::lazy()->filter(fn($u) => $u->is_active)->each(fn($u) => ProcessUser::dispatch($u)); ``` --- ## Factories ```php // database/factories/PostFactory.php class PostFactory extends Factory { protected $model = Post::class; public function definition(): array { return [ 'user_id' => User::factory(), // auto-create related 'title' => $this->faker->sentence(), 'slug' => $this->faker->unique()->slug(), 'body' => $this->faker->paragraphs(3, true), 'status' => 'published', 'published_at' => $this->faker->dateTimeBetween('-1 year'), ]; } // States - modifiers public function draft(): static { return $this->state(['status' => 'draft', 'published_at' => null]); } public function featured(): static { return $this->state(['status' => 'featured']); } public function withTags(int $count = 3): static { return $this->afterCreating(function (Post $post) use ($count) { $post->tags()->attach(Tag::factory()->count($count)->create()); }); } // Sequence - vary per-record public function configure(): static { return $this->sequence( ['status' => 'draft'], ['status' => 'published'], ['status' => 'archived'], ); } } // Usage in tests or seeders Post::factory()->create(); // single Post::factory()->count(10)->create(); // 10 records Post::factory()->draft()->create(); // apply state Post::factory()->featured()->withTags(5)->create(); // chain states Post::factory()->for(User::factory()->admin())->create(); // explicit relationship Post::factory()->has(Comment::factory()->count(3))->create(); // hasMany Post::factory()->hasComments(3)->create(); // magic has method // In-memory (not persisted) Post::factory()->make(); Post::factory()->makeMany(5); // Sequences Post::factory()->count(3)->sequence( ['status' => 'draft'], ['status' => 'published'], ['status' => 'archived'], )->create(); // afterCreating callback Post::factory()->afterCreating(function (Post $post) { $post->searchIndex()->create(['content' => $post->body]); })->create(); ``` --- ## Advanced Patterns ### Subquery Selects for Aggregates (avoid N+1) ```php // Instead of: $users->each(fn($u) => $u->posts->count()) // Do this: $users = User::addSelect([ 'posts_count' => Post::selectRaw('COUNT(*)') ->whereColumn('user_id', 'users.id'), 'last_post_at' => Post::select('created_at') ->whereColumn('user_id', 'users.id') ->latest() ->limit(1), ])->get(); ``` ### Upsert ```php // Single upsert User::updateOrCreate( ['email' => 'user@example.com'], // find by ['name' => 'John', 'role' => 'admin'] // update/create with ); // Bulk upsert (one query) Post::upsert( [ ['id' => 1, 'title' => 'Updated', 'slug' => 'updated'], ['id' => 2, 'title' => 'New Post', 'slug' => 'new-post'], ], uniqueBy: ['slug'], // conflict column(s) update: ['title'] // columns to update on conflict ); ``` ### Locking for Concurrency ```php // Shared lock (read lock - prevent other writes) $order = Order::where('id', $id)->sharedLock()->first(); // Exclusive lock (write lock - prevent other reads and writes) DB::transaction(function () use ($orderId) { $order = Order::where('id', $orderId)->lockForUpdate()->first(); $order->decrement('quantity'); }); ``` -
testing-auth.md 27.1 KB
# Testing and Authentication Reference Deep-dive reference for PHPUnit/Pest testing, Sanctum, Fortify, policies, form requests, and browser testing with Dusk. --- ## PHPUnit Setup ### phpunit.xml ```xml <?xml version="1.0" encoding="UTF-8"?> <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true"> <testsuites> <testsuite name="Unit"> <directory suffix="Test.php">./tests/Unit</directory> </testsuite> <testsuite name="Feature"> <directory suffix="Test.php">./tests/Feature</directory> </testsuite> </testsuites> <source> <include> <directory suffix=".php">./app</directory> </include> </source> <php> <env name="APP_ENV" value="testing"/> <env name="APP_KEY" value="base64:test-key-32-chars-here-padded"/> <env name="CACHE_STORE" value="array"/> <env name="DB_CONNECTION" value="sqlite"/> <env name="DB_DATABASE" value=":memory:"/> <env name="MAIL_MAILER" value="array"/> <env name="QUEUE_CONNECTION" value="sync"/> <env name="SESSION_DRIVER" value="array"/> </php> </phpunit> ``` ### Test Databases ```php // Option 1: SQLite in-memory (fastest) // .env.testing DB_CONNECTION=sqlite DB_DATABASE=:memory: // Option 2: Separate MySQL test database DB_CONNECTION=mysql DB_DATABASE=app_testing // Option 3: Per-test transaction rollback (fastest for MySQL) use Illuminate\Foundation\Testing\DatabaseTransactions; // Option 4: Migrate fresh per test class (safest, slowest) use Illuminate\Foundation\Testing\RefreshDatabase; ``` --- ## Pest PHP (Preferred in Laravel 11+) ### Project Setup ```bash composer require pestphp/pest pestphp/pest-plugin-laravel --dev php artisan pest:install ``` ### File Structure and Syntax ```php // tests/Feature/PostTest.php use App\Models\{Post, User}; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); // Group related tests describe('Post creation', function () { beforeEach(function () { $this->user = User::factory()->create(); $this->actingAs($this->user); }); it('creates a post with valid data', function () { $response = $this->post('/posts', [ 'title' => 'My First Post', 'body' => 'Post content here.', ]); $response->assertRedirect(); $this->assertDatabaseHas('posts', ['title' => 'My First Post']); }); it('requires a title', function () { $response = $this->post('/posts', ['body' => 'Content']); $response->assertInvalid(['title']); }); it('is pending future implementation')->todo(); }); // Top-level tests test('guests cannot create posts', function () { $this->post('/posts', ['title' => 'Test'])->assertRedirect('/login'); }); ``` ### Pest Expectations ```php // Chained expectations expect($value) ->toBeTrue() ->not->toBeNull() ->toEqual('expected') ->toBeString() ->toHaveCount(3) ->toContain('substring') ->toMatchArray(['key' => 'value']) ->toHaveKey('name') ->toHaveKeys(['id', 'name', 'email']) ->toBeBetween(1, 10) ->toBeGreaterThan(5) ->toBeLessThanOrEqual(100) ->toBeInstanceOf(User::class) ->toBeNull() ->toBeEmpty() ->toThrow(InvalidArgumentException::class, 'message'); // Higher-order expectations expect([1, 2, 3])->each->toBeInt(); expect($users)->each->toBeInstanceOf(User::class); // Expectations on collections expect($users)->sequence( fn($user) => $user->name->toBe('Alice'), fn($user) => $user->name->toBe('Bob'), ); ``` ### Datasets ```php it('validates email format', function (string $email, bool $valid) { $response = $this->post('/register', ['email' => $email]); if ($valid) { $response->assertValid(['email']); } else { $response->assertInvalid(['email']); } })->with([ ['valid@example.com', true], ['not-an-email', false], ['missing@', false], ['@nodomain.com', false], ]); // Shared datasets // tests/Datasets/emails.php dataset('invalid_emails', ['not-email', '@nodomain', 'missing@tld']); ``` ### Architectural Testing ```php // tests/Architecture/AppTest.php arch('controllers do not use Eloquent directly') ->expect('App\Http\Controllers') ->not->toUse(['Illuminate\Database\Eloquent\Model']); arch('actions are invokable') ->expect('App\Actions') ->toBeClasses() ->toHaveSuffix('Action'); arch('models extend Eloquent') ->expect('App\Models') ->toExtend('Illuminate\Database\Eloquent\Model'); arch('no debug functions in production code') ->expect('App') ->not->toUse(['dd', 'dump', 'ray', 'var_dump']); ``` --- ## HTTP Tests ### Basic HTTP Testing ```php // GET requests $response = $this->get('/posts'); $response = $this->getJson('/api/posts'); // sets Accept: application/json // POST / PUT / PATCH / DELETE $response = $this->post('/posts', $data); $response = $this->postJson('/api/posts', $data); $response = $this->put('/posts/1', $data); $response = $this->patch('/posts/1', ['status' => 'published']); $response = $this->delete('/posts/1'); // With headers $response = $this->withHeaders(['X-Custom-Header' => 'value'])->get('/api/data'); // With cookies $response = $this->withCookie('token', 'abc')->get('/dashboard'); // Follow redirects $response = $this->followingRedirects()->post('/posts', $data); ``` ### Response Assertions ```php // Status codes $response->assertOk(); // 200 $response->assertCreated(); // 201 $response->assertAccepted(); // 202 $response->assertNoContent(); // 204 $response->assertMovedPermanently(); // 301 $response->assertFound(); // 302 $response->assertNotModified(); // 304 $response->assertBadRequest(); // 400 $response->assertUnauthorized(); // 401 $response->assertPaymentRequired(); // 402 $response->assertForbidden(); // 403 $response->assertNotFound(); // 404 $response->assertMethodNotAllowed(); // 405 $response->assertUnprocessable(); // 422 $response->assertTooManyRequests(); // 429 $response->assertServerError(); // 500 $response->assertStatus(418); // custom // Redirect $response->assertRedirect('/home'); $response->assertRedirectToRoute('dashboard'); $response->assertRedirectContains('/orders'); // View $response->assertViewIs('posts.index'); $response->assertViewHas('posts'); $response->assertViewHas('user', fn($user) => $user->id === 1); $response->assertSee('Hello World'); $response->assertSeeText('Hello World'); // strips HTML $response->assertDontSee('Error'); // JSON $response->assertJson(['status' => 'ok', 'data' => ['id' => 1]]); $response->assertJsonFragment(['email' => 'user@example.com']); $response->assertJsonPath('data.user.name', 'John'); $response->assertJsonPath('data.*.id', [1, 2, 3]); $response->assertJsonCount(3, 'data'); $response->assertJsonStructure([ 'data' => [ '*' => ['id', 'title', 'created_at'], ], 'meta' => ['total', 'per_page'], ]); $response->assertJsonMissing(['password', 'remember_token']); $response->assertExactJson(['key' => 'value']); // exact match // Headers and cookies $response->assertHeader('Content-Type', 'application/json'); $response->assertCookie('session'); $response->assertCookieMissing('auth_token'); // Session $response->assertSessionHas('success'); $response->assertSessionHasErrors(['email', 'password']); $response->assertSessionMissing('error'); // Validation errors $response->assertValid(['name', 'email']); $response->assertInvalid(['email' => 'invalid email format']); ``` --- ## Database Testing ### Traits ```php use Illuminate\Foundation\Testing\RefreshDatabase; // Migrates fresh for every test class (drops + re-migrates). Slower but safe. use Illuminate\Foundation\Testing\DatabaseTransactions; // Wraps each test in a transaction, rolls back. Fast, but doesn't work with external processes. use Illuminate\Foundation\Testing\DatabaseMigrations; // Migrates before the test suite, rolls back after. Per-file. ``` ### Database Assertions ```php $this->assertDatabaseHas('users', [ 'email' => 'user@example.com', 'role' => 'admin', ]); $this->assertDatabaseMissing('users', [ 'email' => 'deleted@example.com', ]); $this->assertDatabaseCount('posts', 5); $this->assertSoftDeleted('posts', ['id' => $post->id]); $this->assertNotSoftDeleted('posts', ['id' => $post->id]); $this->assertDatabaseEmpty('cache'); // Model-based assertions $this->assertModelExists($post); $this->assertModelMissing($deletedPost); ``` ### Factory Usage in Tests ```php // Create persisted records $user = User::factory()->create(); $user = User::factory()->admin()->create(['name' => 'Override Name']); // Create without persisting $user = User::factory()->make(); // Create multiple $users = User::factory()->count(5)->create(); // Create with relationships $post = Post::factory() ->for(User::factory()->admin()) ->hasComments(3) ->withTags(5) ->create(); // Seed specific data $this->seed(RoleSeeder::class); $this->seed([RoleSeeder::class, PermissionSeeder::class]); ``` --- ## Mocking Facades ### Mail ```php Mail::fake(); $this->post('/checkout', $orderData); Mail::assertSent(OrderConfirmationMail::class); Mail::assertSent(OrderConfirmationMail::class, 1); // sent exactly once Mail::assertSent(OrderConfirmationMail::class, fn($mail) => $mail->hasTo('customer@example.com') && $mail->hasSubject('Your Order Confirmation') ); Mail::assertNotSent(RefundMail::class); Mail::assertQueued(WeeklyNewsletterMail::class); // queued, not sent Mail::assertNothingSent(); ``` ### Notification ```php Notification::fake(); $this->post('/orders', $data); Notification::assertSentTo($user, InvoicePaidNotification::class); Notification::assertSentTo($user, InvoicePaidNotification::class, fn($n) => $n->invoice->id === $invoiceId ); Notification::assertNotSentTo($admin, InvoicePaidNotification::class); Notification::assertCount(2); Notification::assertNothingSent(); // On-demand notifications Notification::assertSentOnDemand(AlertNotification::class, fn($n, $routes) => $routes->hasRoute('mail', 'ops@example.com') ); ``` ### Event ```php Event::fake(); // Or fake only specific events: Event::fake([OrderPlaced::class, PaymentProcessed::class]); $this->post('/orders', $data); Event::assertDispatched(OrderPlaced::class); Event::assertDispatched(OrderPlaced::class, fn($e) => $e->order->id === $orderId); Event::assertDispatchedTimes(StockUpdated::class, 3); Event::assertNotDispatched(OrderCancelled::class); Event::assertListening(OrderPlaced::class, SendOrderConfirmation::class); Event::assertNothingDispatched(); ``` ### Queue / Bus ```php Queue::fake(); $this->post('/upload', $fileData); Queue::assertPushed(ProcessUpload::class); Queue::assertPushed(ProcessUpload::class, fn($job) => $job->filename === 'test.csv'); Queue::assertPushedOn('imports', ProcessUpload::class); Queue::assertNotPushed(NotifyAdmin::class); Queue::assertCount(2); Queue::assertNothingPushed(); // Bus for batches and chains Bus::fake(); Bus::assertChained([ValidateData::class, ProcessData::class, NotifyUser::class]); Bus::assertBatched(fn($batch) => $batch->jobs->count() === 100); ``` ### HTTP Client ```php Http::fake([ 'api.stripe.com/v1/charges' => Http::response([ 'id' => 'ch_123', 'status' => 'succeeded', ], 200), 'api.sendgrid.com/*' => Http::response(['message' => 'success'], 202), '*' => Http::response('Not mocked', 404), // catch-all ]); // Simulate failure Http::fake(['api.stripe.com/*' => Http::response(['error' => 'declined'], 402)]); // Sequence of responses Http::fake([ 'api.example.com/*' => Http::sequence() ->push(['data' => []], 200) ->push(['data' => [1]], 200) ->pushStatus(429), // rate limit on 3rd call ]); // Assert requests were made Http::assertSent(fn($request) => $request->url() === 'https://api.stripe.com/v1/charges' && $request['amount'] === 2000 ); Http::assertSentCount(3); Http::assertNotSent(fn($request) => str_contains($request->url(), 'sendgrid')); ``` ### Storage ```php Storage::fake('s3'); $this->post('/avatars', ['photo' => UploadedFile::fake()->image('photo.jpg')]); Storage::disk('s3')->assertExists('avatars/photo.jpg'); Storage::disk('s3')->assertMissing('avatars/old.jpg'); ``` --- ## Sanctum Authentication ### API Token Authentication ```php // Installation composer require laravel/sanctum php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider" php artisan migrate // User model use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { use HasApiTokens; } // Issue token (login endpoint) $token = $user->createToken('mobile-app', ['orders:read', 'orders:write']); return response()->json(['token' => $token->plainTextToken]); // Check abilities $user->tokenCan('orders:read'); // bool $user->currentAccessToken(); // PersonalAccessToken model // Token expiration (config/sanctum.php) 'expiration' => 60 * 24 * 7, // 7 days in minutes // Revoke tokens $user->tokens()->delete(); // all tokens $user->currentAccessToken()->delete(); // current only ``` ### Testing with Sanctum ```php use Laravel\Sanctum\Sanctum; // Authenticate as user (no real token needed) Sanctum::actingAs($user); Sanctum::actingAs($user, ['orders:read', 'orders:write']); // with abilities // Feature test examples it('returns orders for authenticated user', function () { Sanctum::actingAs(User::factory()->create(), ['orders:read']); Order::factory()->count(3)->for(auth()->user())->create(); $this->getJson('/api/orders') ->assertOk() ->assertJsonCount(3, 'data'); }); it('rejects requests without valid token', function () { $this->getJson('/api/orders')->assertUnauthorized(); }); it('enforces token abilities', function () { Sanctum::actingAs(User::factory()->create(), ['orders:read']); // no write ability $this->postJson('/api/orders', $data)->assertForbidden(); }); ``` ### SPA Authentication (Cookie-based) ```php // Frontend must first hit GET /sanctum/csrf-cookie // Then POST /login with credentials // Subsequent requests use session cookie + X-XSRF-TOKEN header // config/sanctum.php 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,localhost:3000')), // routes/api.php Route::middleware('auth:sanctum')->get('/user', fn(Request $request) => $request->user()); // CORS (config/cors.php) 'paths' => ['api/*', 'sanctum/csrf-cookie'], 'allowed_origins' => ['http://localhost:3000'], 'supports_credentials' => true, ``` --- ## Fortify (Headless Authentication) ```bash composer require laravel/fortify php artisan vendor:publish --provider="Laravel\Fortify\FortifyServiceProvider" php artisan migrate ``` ### Configuration ```php // config/fortify.php 'features' => [ Features::registration(), Features::resetPasswords(), Features::emailVerification(), Features::updateProfileInformation(), Features::updatePasswords(), Features::twoFactorAuthentication([ 'confirm' => true, 'confirmPassword' => true, ]), ], ``` ### Customizing Actions ```php // app/Actions/Fortify/CreateNewUser.php class CreateNewUser implements CreatesNewUsers { public function create(array $input): User { Validator::make($input, [ 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'unique:users'], 'password' => ['required', Password::defaults(), 'confirmed'], ])->validate(); return DB::transaction(function () use ($input) { $user = User::create([ 'name' => $input['name'], 'email' => $input['email'], 'password' => Hash::make($input['password']), ]); $user->assignRole('user'); // spatie/laravel-permission event(new Registered($user)); return $user; }); } } // FortifyServiceProvider::boot() Fortify::createUsersUsing(CreateNewUser::class); Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class); Fortify::updateUserPasswordsUsing(UpdateUserPassword::class); Fortify::resetUserPasswordsUsing(ResetUserPassword::class); ``` --- ## Policies and Gates ### Defining a Policy ```php // php artisan make:policy PostPolicy --model=Post class PostPolicy { // Gates receive user as first arg (nullable for guests) public function viewAny(?User $user): bool { return true; // anyone can list posts } public function view(?User $user, Post $post): bool { return $post->is_published || $user?->id === $post->user_id; } public function create(User $user): bool { return $user->hasVerifiedEmail(); } public function update(User $user, Post $post): bool { return $user->id === $post->user_id || $user->isAdmin(); } public function delete(User $user, Post $post): bool { return $user->id === $post->user_id || $user->isAdmin(); } public function restore(User $user, Post $post): bool { return $user->isAdmin(); } public function forceDelete(User $user, Post $post): bool { return $user->isAdmin(); } } ``` ### Registering Policies (Laravel 11+ auto-discovery) ```php // Auto-discovered if model/policy naming convention followed // OR manual registration in AppServiceProvider::boot(): Gate::policy(Post::class, PostPolicy::class); ``` ### Using Policies ```php // Controller class PostController extends Controller { public function update(Request $request, Post $post): RedirectResponse { $this->authorize('update', $post); // ... } // Resource controller - authorize all methods at once public function __construct() { $this->authorizeResource(Post::class, 'post'); } } // Route-level middleware Route::put('/posts/{post}', [PostController::class, 'update']) ->middleware('can:update,post'); // Blade @can('update', $post) ... @endcan @cannot('delete', $post) ... @endcannot // Manual check if (Gate::allows('update', $post)) { ... } if (Gate::denies('delete', $post)) { abort(403); } // Before all policy checks (super-admin bypass) Gate::before(fn(User $user) => $user->isSuperAdmin() ? true : null); ``` ### Testing Policies ```php it('allows post author to update their post', function () { $user = User::factory()->create(); $post = Post::factory()->for($user)->create(); $this->actingAs($user) ->put("/posts/{$post->id}", ['title' => 'Updated']) ->assertOk(); }); it('prevents non-author from updating post', function () { $author = User::factory()->create(); $visitor = User::factory()->create(); $post = Post::factory()->for($author)->create(); $this->actingAs($visitor) ->put("/posts/{$post->id}", ['title' => 'Hacked']) ->assertForbidden(); }); ``` --- ## Form Requests ### Request Class ```php // php artisan make:request StorePostRequest class StorePostRequest extends FormRequest { // Who can make this request? public function authorize(): bool { return $this->user()->hasVerifiedEmail(); } // Validation rules public function rules(): array { return [ 'title' => ['required', 'string', 'min:5', 'max:255'], 'body' => ['required', 'string', 'min:50'], 'status' => ['required', Rule::in(['draft', 'published'])], 'tags' => ['nullable', 'array', 'max:5'], 'tags.*' => ['integer', 'exists:tags,id'], 'image' => ['nullable', 'image', 'max:2048', 'mimes:jpg,png,webp'], 'published_at' => ['nullable', 'date', 'after:now', Rule::requiredIf($this->status === 'published')], ]; } // Transform input before validation public function prepareForValidation(): void { $this->merge([ 'slug' => Str::slug($this->title ?? ''), 'status' => $this->status ?? 'draft', ]); } // Custom error messages public function messages(): array { return [ 'title.required' => 'A post title is required.', 'body.min' => 'Posts must be at least 50 characters.', ]; } // Custom attribute names in error messages public function attributes(): array { return [ 'published_at' => 'publication date', ]; } // After validation hook (complex cross-field validation) public function after(): array { return [ function (Validator $validator) { if ($this->hasFile('image') && $this->status === 'draft') { $validator->errors()->add('image', 'Images cannot be added to draft posts.'); } }, ]; } // Safe data for controller use // $request->validated() - only validated fields // $request->safe()->only(['title', 'body']) - subset // $request->safe()->except(['tags']) - exclude } ``` ### Testing Form Requests ```php it('creates a post with valid data', function () { $user = User::factory()->verified()->create(); $this->actingAs($user)->postJson('/posts', [ 'title' => 'A Valid Post Title', 'body' => str_repeat('a', 50), // meet min:50 'status' => 'draft', ])->assertCreated(); }); it('requires a title', function () { $this->actingAs(User::factory()->verified()->create()) ->postJson('/posts', ['body' => str_repeat('a', 50), 'status' => 'draft']) ->assertUnprocessable() ->assertJsonValidationErrors(['title']); }); // Test the form request class directly (unit test) it('validates correctly', function () { $request = StorePostRequest::create('/posts', 'POST', [ 'title' => 'Valid Title', 'body' => str_repeat('a', 50), 'status' => 'draft', ]); $validator = Validator::make($request->all(), (new StorePostRequest)->rules()); expect($validator->fails())->toBeFalse(); }); ``` --- ## Middleware Testing ```php // Test route with middleware applied it('redirects unauthenticated users', function () { $this->get('/dashboard')->assertRedirect('/login'); }); // Test with middleware excluded it('processes request without auth in test', function () { $response = $this->withoutMiddleware(Authenticate::class)->get('/dashboard'); $response->assertOk(); }); // Exclude all middleware $this->withoutMiddleware()->get('/dashboard'); // Exclude CSRF for POST tests (alternative to using withHeaders) // Usually unnecessary if using postJson() or RefreshDatabase ``` --- ## Browser Testing with Dusk ### Setup ```bash composer require laravel/dusk --dev php artisan dusk:install # Update APP_URL in .env.dusk.local # Start Chrome: php artisan dusk:chrome-driver # Run tests: php artisan dusk ``` ### Test Structure ```php // tests/Browser/LoginTest.php use Laravel\Dusk\Browser; use Tests\DuskTestCase; class LoginTest extends DuskTestCase { public function test_user_can_login(): void { $user = User::factory()->create(['password' => Hash::make('password')]); $this->browse(function (Browser $browser) use ($user) { $browser->visit('/login') ->type('email', $user->email) ->type('password', 'password') ->press('Login') ->assertPathIs('/dashboard') ->assertSee('Welcome back'); }); } public function test_user_can_upload_avatar(): void { $user = User::factory()->create(); $this->browse(function (Browser $browser) use ($user) { $browser->loginAs($user) ->visit('/settings/profile') ->attach('avatar', __DIR__.'/../fixtures/avatar.jpg') ->press('Save') ->assertSee('Profile updated'); }); } } ``` ### Dusk Selectors and Assertions ```php $browser ->visit('/posts') ->assertTitle('Posts - My App') ->assertSee('Latest Posts') ->assertDontSee('Error') ->click('@create-post-btn') // dusk="create-post-btn" attribute ->pause(500) // ms - prefer waitFor instead ->waitFor('.modal', 5) // wait up to 5s ->waitForText('Post created') ->waitUntilMissing('.spinner') ->assertVisible('#post-form') ->assertMissing('.error-message') ->type('input[name=title]', 'My Post') ->select('select[name=status]', 'published') ->check('input[name=featured]') ->uncheck('input[name=notify]') ->radio('input[name=type]', 'article') ->screenshot('after-form-fill') // saves to tests/Browser/screenshots/ ->assertInputValue('title', 'My Post') ->assertChecked('featured') ->press('Submit') ->assertPathIs('/posts') ->assertRouteIs('posts.index'); // JavaScript execution $browser->script('document.querySelector(".modal").remove()'); $value = $browser->value('#hidden-input'); // Multiple browsers (for real-time features) $this->browse(function (Browser $alice, Browser $bob) { $alice->loginAs($this->user)->visit('/chat'); $bob->loginAs($this->otherUser)->visit('/chat') ->type('#message', 'Hello!') ->press('Send'); $alice->waitForText('Hello!')->assertSee('Hello!'); }); ``` --- ## Test Helpers and Utilities ### Custom Test Helpers ```php // tests/TestCase.php - add reusable methods abstract class TestCase extends BaseTestCase { protected function signIn(?User $user = null): User { $user ??= User::factory()->create(); $this->actingAs($user); return $user; } protected function signInAsAdmin(): User { $admin = User::factory()->admin()->create(); $this->actingAs($admin); return $admin; } protected function assertValidationError(TestResponse $response, string $field): void { $response->assertUnprocessable() ->assertJsonValidationErrors([$field]); } } ``` ### Parallel Testing ```bash # Run tests in parallel (requires brianium/paratest) composer require brianium/paratest --dev php artisan test --parallel php artisan test --parallel --processes=4 ``` ```php // Use separate test database per process // phpunit.xml: <env name="DB_DATABASE" value="app_testing_${TEST_TOKEN}"/> // Or configure in ParallelRunner ``` ### Test-Specific Configuration ```php // .env.testing overrides MAIL_MAILER=array QUEUE_CONNECTION=sync CACHE_STORE=array SESSION_DRIVER=array // Per-test config override Config::set('mail.default', 'array'); Config::set('queue.default', 'sync'); // Freeze time (Carbon) $this->travelTo(now()->setDate(2024, 1, 15)); $this->travelBack(); Carbon::setTestNow('2024-01-15 12:00:00'); Carbon::setTestNow(); // reset ```
-
-
scripts
-
.gitkeep 0 B · in bundle
-
-
SKILL.md 16.6 KB
--- name: laravel-ops description: "Laravel framework patterns, Eloquent ORM, authentication, queues, and testing. Use for: laravel, eloquent, artisan, blade, php, sanctum, livewire, inertia, pest, phpunit, forge, vapor, queue, middleware, migration, factory, seeder." when_to_use: "Use when building Laravel 11+ apps — e.g. 'write an Eloquent query or migration', 'set up Sanctum auth', 'configure queues and jobs', 'test with Pest or PHPUnit'. Covers artisan, Blade, Livewire/Inertia, Forge/Vapor, factories, and seeders." license: MIT allowed-tools: "Read Write Bash" metadata: author: claude-mods related-skills: sql-ops, postgres-ops, testing-ops, docker-ops --- # Laravel Operations > Facts verified as of 2026-07. Authoritative reference for Laravel 11+ development: architecture decisions, Eloquent patterns, authentication strategies, queue configuration, and testing approaches. --- ## Architecture Decision Tree ``` What type of application? │ ├─ Full-stack web (HTML responses) │ ├─ Simple CRUD, small team → Monolith (Blade + Eloquent directly) │ │ └─ Use action classes for business logic over 20 lines │ ├─ Rich interactivity needed → Livewire (server-driven reactivity) │ │ └─ Add Alpine.js for client-side micro-interactions │ └─ SPA-like feel, React/Vue team → Inertia.js │ └─ Keep server-side routing, dump client-side routing overhead │ ├─ API backend (JSON responses) │ ├─ Single consumer (mobile/SPA) → API-only with Sanctum SPA auth │ ├─ Multiple consumers / public → RESTful API with token auth │ └─ Complex graph queries → Consider GraphQL (lighthouse-php/lighthouse) │ ├─ Large team / complex domain │ ├─ Domain-driven → Modular monolith (app/Modules/{Domain}/) │ │ ├─ Each module: Models, Actions, Events, Jobs, Http/ │ │ └─ Shared: app/Shared/ for cross-cutting concerns │ └─ Independent deployability needed → Microservices │ └─ Use Laravel Octane for high-throughput services │ └─ What business logic pattern? ├─ Simple CRUD, < 20 lines → Direct Eloquent in controller ├─ Reusable operation (create order, send invoice) → Action class │ └─ Single public handle() or execute() method ├─ Complex queries, multiple data sources → Repository pattern │ └─ Interface + Eloquent implementation (enables swapping) └─ Cross-cutting operations (audit, caching) → Service class └─ Inject via constructor, bind in ServiceProvider ``` ### Action Class vs Repository vs Service | Pattern | Use When | Example | |---------|----------|---------| | Action class | Single, reusable business operation | `CreateOrderAction`, `SendInvoiceAction` | | Repository | Abstract data access, multiple sources | `OrderRepository` with `EloquentOrderRepository` | | Service | Orchestrate multiple actions/repos | `OrderService` combining payment + inventory | | Direct Eloquent | Simple CRUD, < 5 lines in controller | `User::create($data)` | --- ## Eloquent Quick Reference ### Relationships | Relationship | Method | Foreign Key Convention | |-------------|--------|----------------------| | `hasOne` | `return $this->hasOne(Profile::class)` | `profiles.user_id` | | `hasMany` | `return $this->hasMany(Post::class)` | `posts.user_id` | | `belongsTo` | `return $this->belongsTo(User::class)` | `posts.user_id` | | `belongsToMany` | `return $this->belongsToMany(Role::class)` | `role_user` pivot | | `hasManyThrough` | `return $this->hasManyThrough(Post::class, User::class)` | Country → User → Post | | `morphTo` | `return $this->morphTo()` | `{col}_type`, `{col}_id` | | `morphMany` | `return $this->morphMany(Comment::class, 'commentable')` | Polymorphic | | `morphToMany` | `return $this->morphToMany(Tag::class, 'taggable')` | Polymorphic pivot | ### Eager Loading ```php // Prevent N+1: always eager load in controllers $posts = Post::with(['author', 'comments.author', 'tags'])->paginate(15); // Conditional eager loading (load after retrieval) $user->load('posts.comments'); $user->loadMissing('posts'); // only if not already loaded // Eager load counts (no SELECT *) $posts = Post::withCount('comments')->get(); // Constrained eager loading $posts = Post::with(['comments' => fn($q) => $q->approved()->latest()])->get(); ``` ### Query Scopes ```php // Local scope (reusable query constraint) public function scopeActive(Builder $query): void { $query->where('status', 'active'); } // Usage: User::active()->get() // Dynamic scope public function scopeOfType(Builder $query, string $type): void { $query->where('type', $type); } // Usage: User::ofType('admin')->get() ``` ### Mass Assignment ```php // Fillable (allowlist - preferred) protected $fillable = ['name', 'email', 'password']; // Guarded (denylist - use [] only if you trust all input) protected $guarded = ['id', 'is_admin']; // Never set guarded = [] in production code ``` --- ## Artisan Command Cheat Sheet | Command | Purpose | Common Options | |---------|---------|----------------| | `make:model Post -mfs` | Model + migration + factory + seeder | `-c` controller, `-r` resource | | `make:controller PostController -r` | Resource controller (7 methods) | `--api` skips create/edit | | `make:request StorePostRequest` | Form request for validation | | | `make:job ProcessPayment` | Queueable job class | `--sync` for sync job | | `make:event OrderPlaced` | Event class | | | `make:listener SendOrderConfirmation -e OrderPlaced` | Listener for event | `--queued` | | `make:notification InvoicePaid` | Notification class | | | `make:policy PostPolicy -m Post` | Policy with model | | | `make:middleware EnsureUserIsAdmin` | HTTP middleware | | | `make:command SendDailyReport` | Custom Artisan command | | | `migrate` | Run pending migrations | `--step` for individual | | `migrate:rollback` | Roll back last batch | `--step=5` | | `migrate:fresh --seed` | Drop all + re-migrate + seed | | | `db:seed` | Run all seeders | `--class=UserSeeder` | | `tinker` | REPL with app context | | | `route:list` | Show all routes | `--name=api` filter | | `route:cache` | Cache routes for production | | | `config:cache` | Cache config for production | | | `view:cache` | Pre-compile Blade templates | | | `optimize` | Run all cache commands | `optimize:clear` to reset | | `queue:work` | Process queue jobs | `--queue=high,default` | | `queue:listen` | Work + auto-reload on code change | | | `queue:failed` | List failed jobs | | | `queue:retry all` | Retry all failed jobs | | | `schedule:run` | Run due scheduled tasks | | | `schedule:work` | Run scheduler every minute (dev) | | | `key:generate` | Generate APP_KEY | | | `test` | Run PHPUnit/Pest tests | `--filter=UserTest` | | `test --parallel` | Run tests in parallel | `--processes=4` | | `vendor:publish` | Publish package assets/config | `--tag=config` | --- ## Authentication Decision Tree ``` What do you need? │ ├─ SPA (Vue/React) + Laravel API backend │ └─ Sanctum SPA authentication │ ├─ Cookie-based (same domain or subdomain) │ ├─ Csrf-cookie endpoint: GET /sanctum/csrf-cookie │ └─ No tokens in localStorage (XSS safe) │ ├─ Mobile app or third-party API consumers │ └─ Sanctum API tokens (Bearer tokens) │ ├─ createToken($name, $abilities) │ ├─ Token abilities for fine-grained control │ └─ Token expiration with token:prune schedule │ ├─ Traditional web app (server-rendered) │ ├─ Just need auth pages quickly → Breeze │ │ ├─ Minimal, educational, Blade or Inertia stack │ │ └─ Install: composer require laravel/breeze --dev │ ├─ Need teams, 2FA, profile management → Jetstream │ │ ├─ Livewire or Inertia stack │ │ └─ Install: composer require laravel/jetstream │ └─ Need headless auth (API + custom UI) → Fortify │ ├─ Actions in app/Actions/Fortify/ │ └─ Customize: CreateNewUser, UpdateUserPassword │ └─ Custom / enterprise ├─ LDAP/SAML → socialiteproviders/saml2 ├─ OAuth social login → laravel/socialite └─ Custom guard → Implement Guard + UserProvider contracts ``` ### Sanctum Quick Setup ```php // config/sanctum.php - stateful domains for SPA 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost')), // API token creation $token = $user->createToken('mobile-app', ['orders:read', 'orders:write']); return ['token' => $token->plainTextToken]; // Check token ability Route::get('/orders', function (Request $request) { $request->user()->tokenCan('orders:read'); // bool }); // Protect routes Route::middleware('auth:sanctum')->group(function () { // authenticated routes }); ``` --- ## Queue Decision Tree ``` Queue driver selection: │ ├─ Development / testing │ └─ sync driver (executes immediately, no worker needed) │ QUEUE_CONNECTION=sync │ ├─ Small app, no Redis available │ └─ database driver │ ├─ php artisan queue:table && migrate │ ├─ Works fine for < 100 jobs/min │ └─ QUEUE_CONNECTION=database │ ├─ Medium-high throughput, self-hosted │ └─ Redis driver (via predis or phpredis) │ ├─ QUEUE_CONNECTION=redis │ ├─ Laravel Horizon for monitoring │ └─ Supports priorities, pausing, metrics │ └─ AWS infrastructure / massive scale └─ SQS driver ├─ QUEUE_CONNECTION=sqs ├─ Managed, auto-scaling └─ Use with Laravel Vapor for serverless ``` ### Job Patterns ```php // Basic job dispatch ProcessPayment::dispatch($order); ProcessPayment::dispatch($order)->onQueue('payments')->delay(now()->addMinutes(5)); // Chaining (sequential) Bus::chain([ new ProcessPayment($order), new SendInvoice($order), new UpdateInventory($order), ])->dispatch(); // Batching (parallel + callback) $batch = Bus::batch([ new ImportRow($row1), new ImportRow($row2), new ImportRow($row3), ])->then(fn(Batch $batch) => ImportComplete::dispatch()) ->catch(fn(Batch $batch, Throwable $e) => Log::error($e)) ->dispatch(); // Rate limiting (throttle to 5 per minute) public function middleware(): array { return [new RateLimited('payments')]; } // Unique jobs (prevent duplicate processing) use Illuminate\Contracts\Queue\ShouldBeUnique; class ProcessPayment implements ShouldQueue, ShouldBeUnique { public string $uniqueId => $this->order->id; public int $uniqueFor = 3600; // seconds } // Retry configuration public int $tries = 3; public int $backoff = 60; // seconds between retries public function retryUntil(): DateTime { return now()->addHours(24); } ``` ### Task Scheduling ```php // routes/console.php (Laravel 11+) Schedule::job(SendDailyReport::class)->dailyAt('08:00')->timezone('America/New_York'); Schedule::command('backup:run')->daily()->runInBackground()->emailOutputOnFailure('ops@app.com'); Schedule::call(fn() => Cache::flush())->weekly()->sundays()->at('00:00'); // Prevent overlap (long-running tasks) Schedule::job(ProcessImport::class)->everyFiveMinutes()->withoutOverlapping(); // Run on one server only (requires Redis/database cache driver) Schedule::job(SendNewsletters::class)->daily()->onOneServer(); ``` --- ## Testing Quick Reference ### Test Types | Type | Class extends | Database | Purpose | |------|--------------|----------|---------| | Feature test | `Tests\TestCase` | Yes (with trait) | HTTP endpoints, full stack | | Unit test | `PHPUnit\Framework\TestCase` | No | Pure logic, no app boot | | Browser test | `Laravel\Dusk\TestCase` | Yes | Real browser via ChromeDriver | ### Database Traits ```php use Illuminate\Foundation\Testing\RefreshDatabase; // migrate fresh each test (slower) use Illuminate\Foundation\Testing\DatabaseTransactions; // rollback each test (faster) ``` ### Pest Syntax (preferred in Laravel 11+) ```php describe('User authentication', function () { beforeEach(function () { $this->user = User::factory()->create(); }); it('allows login with valid credentials', function () { $response = $this->post('/login', [ 'email' => $this->user->email, 'password' => 'password', ]); $response->assertRedirect('/dashboard'); $this->assertAuthenticatedAs($this->user); }); it('rejects invalid credentials')->todo(); }); ``` ### Common Assertions ```php // HTTP response $response->assertStatus(200); $response->assertOk(); // 200 $response->assertCreated(); // 201 $response->assertNoContent(); // 204 $response->assertUnauthorized(); // 401 $response->assertForbidden(); // 403 $response->assertNotFound(); // 404 $response->assertRedirect('/home'); // JSON responses $response->assertJson(['status' => 'ok']); $response->assertJsonPath('data.email', 'user@example.com'); $response->assertJsonCount(3, 'data'); $response->assertJsonStructure(['data' => ['id', 'name', 'email']]); $response->assertJsonMissing(['password']); // Database $this->assertDatabaseHas('users', ['email' => 'user@example.com']); $this->assertDatabaseMissing('users', ['email' => 'deleted@example.com']); $this->assertDatabaseCount('posts', 5); $this->assertSoftDeleted('posts', ['id' => $post->id]); ``` --- ## Common Gotchas | Gotcha | Why | Fix | |--------|-----|-----| | N+1 queries on relationships | Eloquent lazy-loads by default | Use `with()` eager loading; enable `Model::preventLazyLoading()` in AppServiceProvider during development | | Mass assignment vulnerability | `$fillable = []` accepts all | Always define `$fillable`; never use `$guarded = []` in production | | `created_at` not updating on `update()` | Only `updated_at` auto-sets | Use `$model->touch()` or `timestamps = true` (default) | | Queue job fails on model serialization | Model state may change between dispatch and processing | Use `SerializesModels` trait; re-fetch from DB in `handle()` if needed | | Timezone mismatch in scheduled tasks | Server tz != app tz | Set `APP_TIMEZONE` in `.env`; use `->timezone()` on schedule entries | | Middleware order matters | Auth middleware must run before policies | Global → route group → route. Auth before throttle check or vice versa changes 401 vs 429 | | Route model binding skips soft-deleted records | `RouteServiceProvider` ignores `trashed()` | Extend binding: `Route::bind('post', fn($id) => Post::withTrashed()->findOrFail($id))` | | Service container binding not auto-resolved | Interface not bound to implementation | Register in `AppServiceProvider::register()`: `$this->app->bind(Interface::class, Implementation::class)` | | Migration foreign key order | Must create referenced table first | Run `migrate:fresh` to verify; use `Schema::disableForeignKeyConstraints()` in tests | | CSRF protection blocks API routes | `VerifyCsrfToken` runs on all web routes | Register API routes in `routes/api.php` (uses `api` middleware group without CSRF) | | `env()` returns null after caching | `config:cache` bakes env values | Always access env via `config()` helper in app code; only use `env()` in `config/` files | | Blade `@stack` renders in wrong order | `@push` must appear after `@stack` in execution | Use `@prepend` for scripts that must appear first | | Event listener not firing | Listener not registered or discovered | Check `EventServiceProvider::$listen`; or enable `Event::discover()` in Laravel 11 | --- ## Reference Files | File | Contents | |------|---------| | `references/eloquent-queries.md` | Deep-dive: relationships, query builder, scopes, accessors, mutators, events, soft deletes, pagination, performance, collections, factories | | `references/architecture.md` | Service container, providers, facades, middleware, events, notifications, jobs, scheduling, Blade components, Livewire, Inertia | | `references/testing-auth.md` | PHPUnit/Pest setup, HTTP tests, database testing, fakes, Sanctum, Fortify, policies, form requests, Dusk | --- ## See Also - `sql-ops` - Query optimization, indexing strategy, raw SQL patterns - `postgres-ops` - PostgreSQL-specific features, JSON columns, full-text search - `testing-ops` - General testing philosophy, TDD, CI integration - `docker-ops` - Containerizing Laravel apps, Docker Compose, production setup ### Key External Resources - [Laravel 11 Documentation](https://laravel.com/docs/11.x) - [Pest PHP](https://pestphp.com/) - [Laravel Horizon](https://laravel.com/docs/11.x/horizon) - Queue monitoring - [Laravel Telescope](https://laravel.com/docs/11.x/telescope) - Local debugging and request/query monitoring - [Laravel Octane](https://laravel.com/docs/11.x/octane) - High-performance serving - [Laravel Forge](https://forge.laravel.com/) - Server management - [Laravel Vapor](https://vapor.laravel.com/) - Serverless deployment
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.