Author: Daryle De Silva

  • Simplify Complex Joins with joinRelationship()

    When you need to join tables based on Eloquent relationships, skip the manual join() calls and use the power-join package’s joinRelationship() method instead.

    The Old Way: Manual Joins

    use Illuminate\Support\Facades\DB;
    
    $report = Report::select('reports.*')
        ->join('external_users', fn ($join) => $join
            ->on(DB::raw('BINARY reports.email'), DB::raw('BINARY external_users.user_email'))
            ->where('external_users.id', config('services.external.user_id'))
        )
        ->firstOrFail();

    This works, but it’s verbose and duplicates your relationship logic. You’re manually specifying the join conditions that are already defined in your Eloquent relationships.

    The Better Way: joinRelationship()

    $report = Report::joinRelationship('externalUser', fn($q) => 
        $q->whereKey(config('services.external.user_id'))
    )->firstOrFail();

    Much cleaner! The joinRelationship() method:

    • Uses your existing Report::externalUser() relationship definition
    • Generates the join automatically based on the relationship type
    • Lets you add WHERE conditions via the closure
    • Keeps your query logic DRY

    Setup

    Install the package:

    composer require kirschbaum-development/eloquent-power-joins

    Define your relationship once in the model:

    // app/Models/Report.php
    class Report extends Model
    {
        public function externalUser(): HasOne
        {
            return $this->hasOne(ExternalUser::class, 
                foreignKey: 'user_email', 
                localKey: 'email'
            );
        }
    }

    Now any query that needs this join can use joinRelationship('externalUser') instead of repeating the join logic.

    Why This Matters

    When your join logic lives in one place (the relationship method), changes only need to happen once. If you later add a scope, change the foreign key, or adjust the join conditions, all your queries automatically pick up the change.

    Bonus: joinRelationship() also works with nested relationships like joinRelationship('company.department'), giving you powerful query composition without the complexity.

  • Track Milestone Events with whereJsonContains()

    Imagine you’re building a social platform where users can like posts. You want to send notifications when a post hits milestones: “Your post just hit 100 likes!”

    But there’s a problem: vote counts can fluctuate. Someone might like, unlike, then like again. You don’t want to spam the author with duplicate “100 likes” notifications every time the count crosses that threshold.

    Store Milestone Data in Notifications

    Laravel’s database notifications store arbitrary data in a JSON column. Use this to track exactly which milestone was sent:

    // In your notification class
    class LikeMilestoneNotification extends Notification
    {
        public function __construct(
            public Post $post,
            public int $milestone
        ) {}
    
        public function toArray($notifiable): array
        {
            return [
                'post_id' => $this->post->getKey(),
                'post_title' => $this->post->title,
                'milestone' => $this->milestone,
                'message' => "Your post hit {$this->milestone} likes!",
            ];
        }
    }
    

    Check for Duplicates with whereJsonContains()

    Before sending a milestone notification, query the JSON data column to see if that exact milestone was already sent:

    use App\Notifications\LikeMilestoneNotification;
    
    public function handleLike($event): void
    {
        $post = $event->like->post;
        
        // Load the total likes count
        $totalLikes = $post->likes()->count();
    
        // Check if we hit a milestone
        $milestones = [1, 5, 10, 25, 50, 100, 250, 500, 1000];
        if (!in_array($totalLikes, $milestones)) {
            return; // Not a milestone, skip
        }
    
        // Check if we already sent this exact milestone notification
        $alreadySent = $post->author->notifications()
            ->where('type', LikeMilestoneNotification::class)
            ->whereJsonContains('data->post_id', $post->getKey())
            ->whereJsonContains('data->milestone', $totalLikes)
            ->exists();
    
        if ($alreadySent) {
            return; // Already notified, skip
        }
    
        // Send the milestone notification
        $post->author->notify(
            new LikeMilestoneNotification($post, $totalLikes)
        );
    }
    

    Why This Works

    whereJsonContains() queries the JSON data column efficiently. By storing both post_id and milestone in the notification data, you can check:

    • Did we send a milestone notification for this specific post?
    • Was it for this exact milestone number?

    If the post later hits 250 likes, the check for milestone 100 will still return true (already sent), but the check for milestone 250 will return false (new milestone).

    Performance Optimization with loadSum()

    If you’re counting relationships frequently (like in an event listener that fires on every like), use loadSum() instead of count():

    // Before: runs a separate query each time
    $totalLikes = $post->likes()->count();
    
    // After: eager load the sum
    $post->loadSum('likes', 'value'); // Assuming likes have a 'value' column (1 or -1)
    $totalLikes = $post->likes_sum_value ?? 0;
    

    Laravel will cache this on the model, so subsequent accesses don’t hit the database.

    Bonus: Exclude Self-Likes

    If users can like their own posts, you probably don’t want to send them milestone notifications for their own activity:

    // Skip if the author liked their own post
    if ($post->author_id === $event->like->user_id) {
        return;
    }
    

    Database Schema

    Laravel’s default notifications table already includes the JSON data column:

    // Generated by: php artisan notifications:table
    Schema::create('notifications', function (Blueprint $table) {
        $table->uuid('id')->primary();
        $table->string('type');
        $table->morphs('notifiable'); // User ID and type
        $table->text('data'); // JSON column for custom data
        $table->timestamp('read_at')->nullable();
        $table->timestamps();
    });
    

    No additional columns needed — just use the data column creatively.

  • Complex Conditional Validation with after() Hook

    Sometimes validation logic for one field depends on another field’s value. For example, imagine a task scheduling form where:

    • If is_scheduled is true, the scheduled_date must be in the future
    • If is_scheduled is false, the scheduled_date (if provided) must be now or in the past

    This type of cross-field validation gets messy with nested validation rules:

    public function rules(): array
    {
        return [
            'is_scheduled' => 'required|boolean',
            'scheduled_date' => [
                'nullable',
                'date',
                function ($attribute, $value, $fail) {
                    // Problem: Can't reliably access 'is_scheduled' here
                    // It might not be validated yet or could be missing
                    $isScheduled = $this->input('is_scheduled');
                    // Fragile logic follows...
                },
            ],
        ];
    }
    

    Use the after() Validation Hook

    Laravel’s after() hook runs after basic validation passes. This means you’re guaranteed that all fields have been validated and are available as clean, typed data:

    use Illuminate\Validation\Validator;
    use Carbon\Carbon;
    
    public function rules(): array
    {
        return [
            'is_scheduled' => 'required|boolean',
            'scheduled_date' => 'nullable|date',
        ];
    }
    
    protected function after(): array
    {
        return [
            function (Validator $validator) {
                $data = $validator->validated();
                $date = $data['scheduled_date'] ?? null;
                $isScheduled = $data['is_scheduled'];
    
                if ($date) {
                    $parsedDate = Carbon::parse($date);
                    
                    if ($isScheduled && $parsedDate->isPast()) {
                        $validator->errors()->add(
                            'scheduled_date',
                            'Scheduled date must be in the future.'
                        );
                    } elseif (!$isScheduled && $parsedDate->isFuture()) {
                        $validator->errors()->add(
                            'scheduled_date',
                            'Date must be now or in the past for completed tasks.'
                        );
                    }
                }
            },
        ];
    }
    

    Why This Works Better

    1. Guaranteed valid data: By the time after() runs, you know is_scheduled is a boolean and scheduled_date (if present) is a valid date.
    2. Cleaner logic: No more nested closures in the rules array.
    3. Better testing: Cross-field logic is isolated and easier to test.

    Bonus: Use Carbon’s Date Helpers

    Instead of manual comparisons with strtotime() or now(), use Carbon’s semantic methods:

    $date->isFuture()      // true if date is after now
    $date->isPast()        // true if date is before now  
    $date->isToday()       // true if date is today
    $date->isTomorrow()    // true if date is tomorrow
    

    Your validation logic becomes self-documenting.

    Real-World Example: Unique Constraints with Nullable Fields

    Another common use case: ensuring uniqueness across multiple fields where some are nullable:

    protected function after(): array
    {
        return [
            function (Validator $validator) {
                $data = $validator->validated();
                
                // Check if this combo already exists
                $exists = Task::where('user_id', $this->user()->id)
                    ->where('project_id', $data['project_id'])
                    ->where('is_scheduled', $data['is_scheduled'])
                    ->where(function ($query) use ($data) {
                        if ($data['scheduled_date']) {
                            $query->whereDate('scheduled_date', $data['scheduled_date']);
                        } else {
                            $query->whereNull('scheduled_date');
                        }
                    })
                    ->exists();
                    
                if ($exists) {
                    $validator->errors()->add(
                        'scheduled_date',
                        'This task already exists with the same scheduling settings.'
                    );
                }
            },
        ];
    }
    

    Database unique constraints skip NULL values, so this application-level check ensures true uniqueness.

  • Handle Checkbox Form Data the Laravel Way

    HTML checkboxes are quirky. When unchecked, they don’t send any value in the POST request. When checked, they send 'on' (or '1' if you set a value attribute). This creates inconsistent backend handling.

    Here’s the manual way many developers handle it:

    // Checking if checkbox was checked
    $isActive = $request->has('is_active') && 
                $request->input('is_active') === 'on';
    
    // Validation is also messy
    $rules = [
        'is_active' => 'nullable|in:on,1,true',
    ];
    

    This works, but you’re still dealing with string values like 'on' instead of actual booleans.

    The Laravel Way: $request->boolean()

    Laravel provides a boolean() method that normalizes checkbox values automatically:

    // Clean, boolean-native handling
    $isActive = $request->boolean('is_active');
    
    // Validation becomes simple
    $rules = [
        'is_active' => 'nullable|boolean',
    ];
    
    // Saving to database
    $task->update([
        'is_active' => $request->boolean('is_active'),
    ]);
    

    The boolean() method returns true for:

    • 'on', '1', 'true', 'yes'
    • Integer 1
    • Boolean true

    And false for everything else, including missing values.

    Why This Matters

    When working with forms that have multiple checkboxes (think feature toggles, permission settings, or configuration options), the boolean() method keeps your controller code clean:

    $task = Task::create([
        'title' => $request->input('title'),
        'is_public' => $request->boolean('is_public'),
        'is_featured' => $request->boolean('is_featured'),
        'requires_approval' => $request->boolean('requires_approval'),
    ]);
    

    No more has() checks, no more string comparisons. Just clean boolean logic that works exactly how HTML checkboxes behave in the browser.

    Bonus: Default Values

    You can also provide a default value when the field is missing:

    // Defaults to true if checkbox isn't present
    $isActive = $request->boolean('is_active', true);
    

    This is particularly useful when you want “enabled by default” behavior.

  • Safe Email Template Refactoring with Before/After Routes

    Refactoring email templates is risky. One typo and your production emails break. Here’s how to iterate safely with side-by-side comparisons.

    The Pattern

    When you need to redesign an email template:

    1. Duplicate the template with -old suffix
    2. Create test routes for both versions
    3. Modify the new version while keeping old as reference
    4. Compare side-by-side before committing

    Step 1: Duplicate Templates

    cd resources/views/emails
    cp notification.blade.php notification-old.blade.php
    

    Now you have:

    • notification.blade.php — Your working copy (will be modified)
    • notification-old.blade.php — Backup (unchanged)

    Step 2: Create Test Routes for Both

    // routes/web.php (local/testing only)
    
    Route::get('/test/email/notification-old', function () {
        $request = App\Models\Request::factory()->make();
        return (new App\Mail\RequestNotification($request))
            ->render('emails.notification-old');
    });
    
    Route::get('/test/email/notification-new', function () {
        $request = App\Models\Request::factory()->make();
        return new App\Mail\RequestNotification($request);
    });
    

    If your mailable uses a hardcoded view name, override it in the route:

    Route::get('/test/email/notification-old', function () {
        $mailable = new App\Mail\RequestNotification($request);
        $mailable->view = 'emails.notification-old'; // Override
        return $mailable;
    });
    

    Step 3: Iterate on the New Version

    Edit notification.blade.php freely. After each change:

    1. Open /test/email/notification-old (baseline)
    2. Open /test/email/notification-new (your changes)
    3. Compare side-by-side

    If something breaks, the old version is still rendering correctly for comparison.

    Step 4: Add State Parameters

    Support different email states in both routes:

    Route::get('/test/email/notification-old', function (Request $request) {
        $status = $request->get('status', 'pending');
        $requestModel = App\Models\Request::factory()->make(['status' => $status]);
        
        $mailable = new App\Mail\RequestNotification($requestModel);
        $mailable->view = 'emails.notification-old';
        return $mailable;
    });
    
    Route::get('/test/email/notification-new', function (Request $request) {
        $status = $request->get('status', 'pending');
        $requestModel = App\Models\Request::factory()->make(['status' => $status]);
        return new App\Mail\RequestNotification($requestModel);
    });
    

    Now compare all states:

    • /test/email/notification-old?status=pending vs /test/email/notification-new?status=pending
    • /test/email/notification-old?status=approved vs /test/email/notification-new?status=approved
    • /test/email/notification-old?status=rejected vs /test/email/notification-new?status=rejected

    When to Commit

    Once the new version looks good across all states:

    1. Delete notification-old.blade.php
    2. Delete the test routes
    3. Commit notification.blade.php

    The -old backup stays in git history if you need it later.

    Why This Works

    • Safe iteration — Original always accessible for comparison
    • Visual confirmation — See exactly what changed
    • Catch regressions — Spot broken states before prod
    • Fast rollback — Just restore the -old file if needed

    Bonus: Use Browser DevTools to Compare

    Open both URLs in separate browser tabs. Use DevTools to inspect HTML structure and spot differences. Chrome’s “Compare” feature in Elements panel is especially useful.

    This pattern works for any high-stakes template refactor—emails, PDFs, invoices, reports. Duplicate, compare, commit.

  • Test Routes for Email Template Development

    Stop sending test emails or checking mail catches every time you tweak an email design. Create test routes that render your templates instantly in the browser.

    The Pattern

    Add temporary routes to your web.php that return your mailable’s rendered view:

    // routes/web.php (or separate routes/testing.php)
    
    Route::get('/test/email/notification', function () {
        $request = App\Models\Request::factory()->make([
            'status' => 'pending',
            'customer_name' => 'Jane Smith',
            'product_name' => 'Monthly Subscription',
        ]);
    
        return new App\Mail\RequestNotification($request);
    });
    

    Hit http://yourdomain.test/test/email/notification in your browser—instant preview.

    Support Multiple States

    Emails often have different layouts based on status (pending, approved, rejected). Use a query parameter to switch between them:

    Route::get('/test/email/notification', function (Request $request) {
        $status = $request->get('status', 'pending');
        
        $requestModel = App\Models\Request::factory()->make([
            'status' => $status,
            'customer_name' => 'Jane Smith',
            'product_name' => 'Monthly Subscription',
        ]);
    
        return new App\Mail\RequestNotification($requestModel);
    });
    

    Now you can preview:

    • /test/email/notification?status=pending
    • /test/email/notification?status=approved
    • /test/email/notification?status=rejected

    Use Factory States for Realistic Data

    If your factories have states for edge cases, use them:

    Route::get('/test/email/notification', function (Request $request) {
        $status = $request->get('status', 'pending');
        
        $requestModel = match ($status) {
            'approved' => App\Models\Request::factory()->approved()->make(),
            'rejected' => App\Models\Request::factory()->rejected()->make(),
            'pending' => App\Models\Request::factory()->pending()->make(),
            default => App\Models\Request::factory()->make(['status' => $status]),
        };
    
        return new App\Mail\RequestNotification($requestModel);
    });
    

    Protect These Routes

    Don’t let test routes hit production. Options:

    1. Environment check in route file

    if (app()->environment('local', 'testing')) {
        Route::get('/test/email/notification', ...);
    }
    

    2. Separate route file loaded conditionally

    // bootstrap/app.php
    if (app()->environment('local', 'testing')) {
        app()->booted(function () {
            require base_path('routes/testing.php');
        });
    }
    

    3. Middleware guard

    Route::middleware(['env:local'])->group(function () {
        Route::get('/test/email/notification', ...);
    });
    

    Why This Helps

    • Instant feedback — No mail queue delays
    • State switching — Preview all variations quickly
    • Designer-friendly — Share URLs for design review
    • Regression testing — Check old templates after refactors

    Once you’re done iterating, delete the routes. They’re scaffolding, not production code.

  • Markdown Whitespace Sensitivity in Laravel Mail Templates

    Laravel’s markdown mail renderer is picky about whitespace. If you’ve ever seen literal **bold** text in your emails instead of actual bold formatting, indentation in your Blade template is probably the culprit.

    The Problem

    When building email templates with Laravel’s Markdown Mailable components, proper indentation makes Blade code readable:

    @component('mail::message')
        @slot('header')
            @component('mail::header', ['url' => config('app.url')])
                <img src="{{ $logoUrl }}" alt="Logo">
            @endcomponent
        @endslot
    
        **Customer Name:** {{ $task->customer_name }}<br>
        **Status:** {{ $task->status }}<br>
    
        Thank you!
    @endcomponent
    

    But when you preview the email, markdown doesn’t render. You see literal asterisks instead of bold text.

    Why It Happens

    Laravel’s mail markdown parser (built on CommonMark) interprets indented lines as code blocks. When your Blade directives add whitespace, lines like:

        **Customer Name:** John Smith
    

    …get parsed as preformatted code, not markdown.

    The Fix

    Remove ALL indentation from markdown content in mail templates:

    @component('mail::message')
    @slot('header')
    @component('mail::header', ['url' => config('app.url')])
    <img src="{{ $logoUrl }}" alt="Logo">
    @endcomponent
    @endslot
    
    **Customer Name:** {{ $task->customer_name }}<br>
    **Status:** {{ $task->status }}<br>
    
    Thank you!
    @endcomponent
    

    Yes, it looks messy in your editor. But your emails will render correctly.

    Alternative: Skip Markdown Entirely

    If you need design control beyond markdown’s capabilities, use plain HTML with inline CSS:

    @component('mail::message')
    <div style="margin-bottom: 16px;">
        <strong>Customer Name:</strong> {{ $task->customer_name }}<br>
        <strong>Status:</strong> {{ $task->status }}
    </div>
    @endcomponent
    

    You get full control over styling, and indentation doesn’t break anything. The trade-off: more verbose markup.

    When to Use Each Approach

    Use markdown (zero indentation) when:

    • Content is simple (text, links, basic formatting)
    • You want Laravel’s default email styling
    • Team prefers concise template syntax

    Use HTML when:

    • You need custom layouts or complex styling
    • Design specs require precise spacing/colors
    • You’re already fighting markdown quirks

    Either way, test your emails in actual mail clients—not just browser previews. Gmail, Outlook, and Apple Mail all render HTML differently.

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