Author: Daryle De Silva

  • UpdateRequest Extends StoreRequest Pattern for DRY Validation

    When creating RESTful resources, your UpdateRequest and StoreRequest often share 95% of the same validation logic. Here’s how to keep it DRY without sacrificing clarity.

    The Problem

    Typical approach duplicates the entire validation:

    // StoreClimbRequest
    public function rules(): array
    {
        return [
            'data.attributes.trail_id' => ['required', 'uuid', 'exists:trails,id'],
            'data.attributes.date' => ['nullable', 'date'],
        ];
    }
    
    public function after(): array
    {
        return [
            function (Validator $validator) {
                // Check uniqueness for trail_id + date
                $exists = Climb::where('author_id', $authorId)
                    ->where('trail_id', $trailId)
                    ->where(function ($query) use ($date) {
                        if ($date === null) {
                            $query->whereNull('date');
                        } else {
                            $query->where('date', $date);
                        }
                    })
                    ->exists();
    
                if ($exists) {
                    $validator->errors()->add('...', '...');
                }
            },
        ];
    }
    
    // UpdateClimbRequest - DUPLICATES EVERYTHING except one ->where()
    class UpdateClimbRequest extends FormRequest
    {
        // Duplicate rules(), duplicate after(), ONLY difference:
        // ->where('id', '!=', $this->route('climb')->id)
    }
    

    The Solution: Extract the Query

    1. Make UpdateRequest extend StoreRequest:

    class UpdateClimbRequest extends StoreClimbRequest
    {
        // Inherits rules() and after() from parent
    }
    

    2. Extract the uniqueness query into a protected method in StoreRequest:

    class StoreClimbRequest extends FormRequest
    {
        public function after(): array
        {
            return [
                function (Validator $validator) {
                    if ($validator->errors()->isNotEmpty()) {
                        return;
                    }
    
                    $attributes = $this->input('data.attributes');
                    $authorId = $this->user()->author->id;
                    $trailId = $attributes['trail_id'];
                    $date = $attributes['date'] ?? null;
    
                    // Use the extracted query method
                    $exists = $this->uniquenessQuery($authorId, $trailId, $date)->exists();
    
                    if ($exists) {
                        $validator->errors()->add(
                            'data.attributes.trail_id',
                            'You have already added this trail' . ($date ? ' for this date' : ' without a date') . '.'
                        );
                    }
                },
            ];
        }
    
        protected function uniquenessQuery(string $authorId, string $trailId, ?string $date)
        {
            return Climb::where('author_id', $authorId)
                ->where('trail_id', $trailId)
                ->where(function ($query) use ($date) {
                    if ($date === null) {
                        $query->whereNull('date');
                    } else {
                        $query->where('date', $date);
                    }
                });
        }
    }
    

    3. Override ONLY the difference in UpdateRequest:

    class UpdateClimbRequest extends StoreClimbRequest
    {
        protected function uniquenessQuery(string $authorId, string $trailId, ?string $date)
        {
            return parent::uniquenessQuery($authorId, $trailId, $date)
                ->where('id', '!=', $this->route('climb')->id);
        }
    }
    

    Result

    • StoreRequest: 60+ lines (full logic)
    • UpdateRequest: 11 lines (just the difference)
    • Zero duplication
    • Easy to maintain – change validation in one place

    When to use this pattern

    • Store and Update share the same validation rules
    • The only difference is excluding the current record from uniqueness checks
    • Custom validation logic in after() hooks
  • Spatie Query Builder Aggregates + Model Casting for Clean API Responses

    When using Spatie Query Builder’s AllowedInclude::sum(), the aggregate attributes come back as numeric strings from MySQL. Here’s how to make them proper integers.

    The Problem

    // Controller
    AllowedInclude::sum('votes_sum_votes', 'votes', 'votes')
    
    // API Response
    {
      "total_votes": "5",  // String! Not ideal.
      "total_upvotes": "3"
    }
    

    The Solution: Model-Level Casting

    1. Use camelCase in the first argument (display name):

    // Controller
    QueryBuilder::for(Comment::query())
        ->allowedIncludes([
            AllowedInclude::sum('totalVotes', 'votes', 'votes'),
            AllowedInclude::sum('totalUpvotes', 'upvotes', 'votes'),
            AllowedInclude::sum('totalDownvotes', 'downvotes', 'votes'),
        ]);
    

    2. Cast the aggregate attributes in your model:

    // Comment model
    protected function casts(): array
    {
        return [
            // Cast the withSum attributes
            'votes_sum_votes' => 'integer',
            'upvotes_sum_votes' => 'integer',
            'downvotes_sum_votes' => 'integer',
        ];
    }
    

    3. Use whenAggregated() in your resource with null coalescing:

    // CommentResource
    public function toMeta(Request $request): array
    {
        return [
            'total_votes' => $this->whenAggregated('votes', 'votes', 'sum', $this->votes_sum_votes ?? 0),
            'total_upvotes' => $this->whenAggregated('upvotes', 'votes', 'sum', $this->upvotes_sum_votes ?? 0),
            'total_downvotes' => $this->whenAggregated('downvotes', 'votes', 'sum', $this->downvotes_sum_votes ?? 0),
        ];
    }
    

    Result

    {
      "meta": {
        "total_votes": 5,
        "total_upvotes": 3,
        "total_downvotes": 0
      }
    }
    

    Why this pattern works

    • Model casts handle type conversion automatically
    • whenAggregated() only includes the field when the sum was loaded
    • ?? 0 ensures you never return null
    • No runtime overhead – casts are applied during hydration
  • UUID Foreign Keys with Cascade Delete in Laravel Migrations

    When working with UUID primary keys in Laravel, use foreignUuid() instead of manually creating UUID foreign keys. It’s cleaner and supports cascade operations out of the box.

    Before (manual approach)

    $table->uuid('user_id');
    $table->foreign('user_id')
        ->references('id')
        ->on('users')
        ->cascadeOnDelete();
    

    After (foreignUuid approach)

    $table->foreignUuid('user_id')
        ->constrained()
        ->cascadeOnDelete();
    

    Even better – use foreignUuid() for polymorphic relationships:

    // Old way
    $table->morphs('votable'); // Creates bigInteger IDs
    
    // UUID way
    $table->uuidMorphs('votable'); // Creates UUID IDs
    

    Why this matters

    • One line instead of three
    • Laravel auto-infers the table name from the column
    • Cascade operations are explicit and readable
    • Works seamlessly with models using HasUuids trait

    Full example

    Schema::create('votes', function (Blueprint $table) {
        $table->uuid('id')->primary();
        $table->foreignUuid('user_id')
            ->constrained('users')
            ->cascadeOnDelete();
        $table->uuidMorphs('votable');
        $table->integer('votes'); // 1 or -1
        $table->timestamps();
    });
    

    When the user or votable model is deleted, votes are automatically cleaned up. No orphaned records.

  • Use Git History to Understand Legacy Code Evolution

    When you inherit a codebase, understanding WHY code exists is often more valuable than WHAT it does. Git history is your time machine.

    The Basic Timeline

    # Get simple commit history for a file
    git log --follow --all --pretty=format:"%h|%ai|%an|%s" -- app/Services/ReportGenerator.php

    Output:

    f4bc663|2018-05-09 17:46:43 +0800|John Smith|TASK-707 Add report tracking
    201105b|2019-03-27 10:02:08 +0800|Jane Doe|Refactor - add dependency injection

    The Full Story (Patches + Stats)

    # See actual code changes with statistics
    git log --follow --all --stat --patch -- app/Services/ReportGenerator.php

    What You Learn

    • Original author: Who to ask if you have questions
    • Creation date: How old this code is (affects modernization priority)
    • Commit message: Why it was added (often references a ticket/task)
    • Evolution pattern: Was it written once and forgotten, or actively maintained?
    • Refactoring history: What patterns were replaced (helps avoid repeating mistakes)

    Reading the Story

    The example above tells us:

    • Created in May 2018 for admin dashboard tracking
    • Refactored in March 2019 to add dependency injection
    • One year gap between changes suggests low-priority maintenance code
    • Two different authors = knowledge might be spread across team

    Pro Tips

    • Use --follow: Tracks files even if renamed (critical!)
    • Filter by date: --since="2020-01-01" to see recent changes only
    • Check for deletions: If no commits since 2020, might be deprecated
    • Look for patterns: Frequent refactors = evolving requirements

    When to Use This

    • Before refactoring legacy code (understand why it’s weird)
    • When debugging mysterious behavior (was it always like this?)
    • During code review (does this change make sense given the history?)
    • When deciding whether to delete code (has it been touched recently?)

    Next time you see weird legacy code, don’t guess — check the git history. It often explains everything.

  • Refactoring Legacy Middleware: From Facades to Dependency Injection

    Legacy Laravel code often relies heavily on facades. While facades are convenient, they can make code harder to test and reason about. Here’s a pattern for refactoring middleware to use dependency injection instead.

    The Legacy Pattern (Bad)

    <?php
    
    namespace App\Http\Middleware;
    
    use Closure;
    use App\Models\ActivityLog;
    
    class TrackActivityStats
    {
        public function handle($request, Closure $next)
        {
            try {
                $route = \Route::current();
                ActivityLog::create([
                    'method' => $request->method(),
                    'uri' => $route->uri(),
                    'route_name' => $route->getName(),
                    'user_id' => \Auth::id()
                ]);
            } catch (\Throwable $e) {}
            return $next($request);
        }
    }

    The Modern Pattern (Good)

    <?php
    
    namespace App\Http\Middleware;
    
    use Closure;
    use Illuminate\Http\Request;
    use Symfony\Component\HttpFoundation\Response;
    use Psr\Log\LogLevel;
    use App\Models\ActivityLog;
    
    class TrackActivityStats
    {
        private $logger;
    
        public function __construct(ActivityLog $logger)
        {
            $this->logger = $logger;
        }
    
        public function handle(Request $request, Closure $next): Response
        {
            $this->logRequest($request);
            return $next($request);
        }
    
        private function logRequest(Request $request)
        {
            try {
                $this->logger->log(LogLevel::INFO, 'Activity tracked', [
                    'method' => $request->method(),
                    'uri' => $request->route()->uri(),
                    'route_name' => $request->route()->getName(),
                    'user_id' => $request->user()->id ?? null
                ]);
            } catch (\Throwable $e) {
                // Silent failure for non-critical tracking
            }
        }
    }

    Why This Is Better

    • Testable: Easy to mock the logger dependency in tests
    • Type-safe: Return type hints catch errors early
    • Explicit dependencies: Constructor shows what the middleware needs
    • No global state: Uses request object instead of facades
    • Separation of concerns: Logic extracted into private method
    • Better IDE support: Type hints enable autocomplete

    Key Changes

    • \Route::current()$request->route()
    • \Auth::id()$request->user()->id
    • Direct model call → Injected logger dependency
    • No type hints → Full type declarations

    Next time you touch old middleware, consider this refactoring pattern. Your future self (and your test suite) will thank you.

  • Run External CLI Commands from Laravel with Process::path()

    Laravel’s Process facade makes it easy to run external command-line tools from your PHP application. The path() method is especially useful when you need to execute commands in a specific working directory.

    Basic Usage

    use Illuminate\Support\Facades\Process;
    
    // Run a script from a specific directory
    Process::path('/opt/scripts')
        ->run(['./backup.sh', '--full'])
        ->throw();

    The path() method changes the working directory before executing the command. This is crucial when scripts depend on relative paths or expect to run from a specific location.

    Real-World Example: Integration Scripts

    Imagine you’re integrating with an external CLI tool installed in a separate directory:

    class DataSyncService
    {
        public function syncUser(User $user): void
        {
            $command = [
                'bin/sync-tool',
                'user',
                'update',
                $user->email,
                '--name=' . $user->name,
                '--active=' . ($user->is_active ? 'true' : 'false'),
            ];
    
            Process::path(config('services.sync.install_path'))
                ->run($command)
                ->throw();  // Throws ProcessFailedException on non-zero exit
        }
    }

    Key Features

    • path($directory): Set working directory
    • run($command): Execute and return result
    • throw(): Fail loudly on errors
    • timeout($seconds): Prevent hanging

    With Timeout Protection

    $result = Process::path('/var/tools')
        ->timeout(30)  // Kill after 30 seconds
        ->run(['./long-task.sh'])
        ->throw();
    
    echo $result->output();

    Handling Output

    $result = Process::path('/opt/reports')
        ->run(['./generate-report.sh', '--format=json']);
    
    if ($result->successful()) {
        $data = json_decode($result->output(), true);
        // Process the data...
    } else {
        Log::error('Report generation failed: ' . $result->errorOutput());
    }

    This pattern is especially useful when integrating third-party tools, running system utilities, or coordinating with non-PHP services that expose CLI interfaces.

  • Auto-Eager Load Common Relationships with Protected $with in Laravel Models

    When certain relationships are always needed with a model, you can eager load them by default using the protected $with property instead of adding with() to every query.

    The Pattern

    Add the $with property to your model:

    class User extends Model
    {
        protected $with = ['profile'];
    
        public function profile(): BelongsTo
        {
            return $this->belongsTo(Profile::class);
        }
    }

    Now every query automatically eager loads the profile:

    // These all include profile automatically:
    User::find(1);
    User::all();
    User::where('active', true)->get();
    
    // No N+1 queries! Profile is always loaded.

    When to Use This

    Perfect for:

    • API resources that always serialize certain relations
    • Computed attributes that depend on relationships
    • Models with logical “parent” relationships

    Example use case: A Comment model where you always need the author:

    class Comment extends Model
    {
        protected $with = ['author'];
    
        public function author(): BelongsTo
        {
            return $this->belongsTo(User::class, 'user_id');
        }
    }

    Disable When Needed

    You can still opt out of auto-eager loading in specific queries:

    // Skip the default eager load:
    User::without('profile')->get();

    Trade-offs

    Pros: Cleaner code, prevents forgotten eager loads, consistent behavior

    Cons: Always eager loads, even when not needed—can add overhead if overused

    Best practice: Combine protected $with for “always needed” relations with whenLoaded() in resources for “sometimes needed” ones.

  • Laravel API Resources: Use whenLoaded() to Conditionally Include Relationships

    When building Laravel API resources, accessing relationships directly can trigger N+1 queries if those relationships aren’t eager loaded. The whenLoaded() method provides an elegant solution.

    The Problem

    Consider this resource implementation:

    class UserResource extends JsonResource
    {
        public function toArray($request)
        {
            return [
                'id' => $this->id,
                'name' => $this->profile->display_name,  // ❌ May trigger query
                'email' => $this->profile->email,        // ❌ May trigger query
            ];
        }
    }

    If profile isn’t eager loaded, every resource instance will execute a separate database query, creating the classic N+1 problem.

    The Solution

    Use whenLoaded() to check if the relationship exists before accessing it:

    class UserResource extends JsonResource
    {
        public function toArray($request)
        {
            return [
                'id' => $this->id,
                'name' => $this->whenLoaded('profile', fn() => $this->profile->display_name),
                'email' => $this->whenLoaded('profile', fn() => $this->profile->email),
                'post_count' => $this->whenLoaded('posts', fn() => $this->posts->count()),
            ];
        }
    }

    Now the resource only accesses profile data when it’s already loaded:

    // Without eager loading - fields are omitted
    UserResource::collection(User::all());
    
    // With eager loading - fields are included
    UserResource::collection(User::with('profile', 'posts')->get());

    Why This Matters

    • Performance: Prevents accidental N+1 queries when relationships aren’t needed
    • Flexibility: Same resource works with or without eager loading
    • Clean errors: No cryptic “trying to get property of null” errors when relations are missing

    This pattern is especially valuable in API endpoints where clients can request different levels of detail via query parameters.

  • Laravel Route Order: Custom Routes Before Resource Routes

    When mixing custom routes with apiResource, order matters. Specific routes must come before parameterized ones.

    The Problem

    // ❌ WRONG: Custom route comes AFTER resource
    Route::apiResource('notifications', NotificationController::class);
    Route::get('/notifications/stats', [NotificationController::class, 'stats']);
    

    Result:

    GET /api/notifications/stats
    # Laravel matches this to show($id='stats') ❌
    # Returns 404: Notification not found
    

    The Solution

    // ✅ CORRECT: Custom routes BEFORE resource
    Route::get('/notifications/stats', [NotificationController::class, 'stats'])
        ->name('notifications.stats');
    
    Route::post('/notifications/actions/mark-all-read', [NotificationController::class, 'markAllAsRead'])
        ->name('notifications.mark-all-read');
    
    Route::apiResource('notifications', NotificationController::class)
        ->only(['index', 'show', 'update', 'destroy']);
    

    Route Matching Order

    Laravel matches routes top to bottom:

    1. GET /notifications/stats → matches first route ✓
    2. POST /notifications/actions/mark-all-read → matches second route ✓
    3. GET /notifications/abc123 → matches apiResource show route ✓

    Naming Convention for Custom Routes

    Use descriptive paths for clarity:

    // Collection-level actions
    Route::get('/users/export', ...);        // GET /api/users/export
    Route::post('/users/import', ...);       // POST /api/users/import
    
    // Named action routes
    Route::post('/orders/actions/bulk-cancel', ...);
    Route::get('/products/stats', ...);
    
    // Avoid generic prefixes that might conflict
    Route::get('/notifications/meta', ...);  // ✓ Good: 'meta' won't be a UUID
    Route::get('/notifications/all', ...);   // ⚠️ Could conflict if 'all' is valid ID
    

    Debug Routes

    Use php artisan route:list to verify order:

    php artisan route:list --path=notifications
    
    # Output shows registration order:
    GET     api/notifications/stats          notifications.stats
    POST    api/notifications/actions/...    notifications.mark-all-read  
    GET     api/notifications                notifications.index
    GET     api/notifications/{notification} notifications.show
    

    Remember: Specific beats generic. Always define custom routes before resource routes.

  • Consolidate Resource Controllers with Flexible Filters

    Instead of creating separate controllers for nested resources (like PostCommentController, UserCommentController), build one controller with flexible filtering using Spatie Query Builder.

    Before: Multiple Controllers

    // app/Http/Controllers/PostCommentController.php
    public function index(Post $post)
    {
        return CommentResource::collection(
            $post->comments()->paginate()
        );
    }
    
    // app/Http/Controllers/UserCommentController.php
    public function index(User $user)
    {
        return CommentResource::collection(
            $user->comments()->paginate()
        );
    }
    

    After: Single Controller with Filters

    // app/Http/Controllers/CommentController.php
    public function index(Request $request)
    {
        $query = QueryBuilder::for(Comment::query())
            ->allowedFilters([
                AllowedFilter::callback('post_id', 
                    fn ($q, $value) => $q->where('post_id', $value)
                ),
                AllowedFilter::callback('user_id', 
                    fn ($q, $value) => $q->where('user_id', $value)
                ),
                AllowedFilter::exact('status'),
            ])
            ->defaultSort('-created_at');
    
        return CommentResource::collection($query->jsonPaginate());
    }
    

    Usage

    # Get comments for a post
    GET /api/comments?filter[post_id]=123
    
    # Get comments by a user
    GET /api/comments?filter[user_id]=456
    
    # Combine filters
    GET /api/comments?filter[post_id]=123&filter[status]=approved
    

    Benefits

    • Single source of truth for filtering logic
    • Easier to maintain and test
    • More flexible API for consumers
    • Reduces route bloat