Category: Laravel

  • Polymorphic Bridge Pattern: Connecting Laravel Models to External Systems

    When integrating Laravel with an external system—a headless CMS, a legacy database, an analytics platform—you often need multiple Laravel models to link to records in that external system. The naive approach is adding a cms_post_id column to each model. But that creates tight coupling and makes future changes painful.

    The better pattern: use a polymorphic pivot table as a bridge layer between your domain models and the external system.

    The Problem

    You have Product, Category, and Tag models. Each can link to posts in an external CMS. The obvious solution:

    // products table
    cms_post_id (bigint)
    
    // categories table
    cms_post_id (bigint)
    
    // tags table
    cms_post_id (bigint)

    Now every model needs CMS-specific logic. Testing requires the CMS. Migrating to a different CMS means updating every model. You’ve tightly coupled your domain to infrastructure.

    The Solution: Polymorphic Bridge

    Create an intermediate pivot table that maps any model to external records:

    // Migration: create_cms_links_table.php
    Schema::create('cms_links', function (Blueprint $table) {
        $table->id();
        $table->morphs('linkable'); // linkable_id, linkable_type
        $table->unsignedBigInteger('cms_post_id');
        $table->timestamps();
        
        $table->unique(['linkable_id', 'linkable_type', 'cms_post_id']);
    });

    Now your models use morphToMany relationships to connect through the bridge:

    // app/Models/Product.php
    public function cmsPosts()
    {
        return $this->morphToMany(
            CmsPost::class,
            'linkable',
            'cms_links',
            'linkable_id',
            'cms_post_id'
        );
    }
    
    // app/Models/Category.php
    public function cmsPosts()
    {
        return $this->morphToMany(
            CmsPost::class,
            'linkable',
            'cms_links',
            'linkable_id',
            'cms_post_id'
        );
    }

    Use it like any relationship:

    $product->cmsPosts()->attach($cmsPostId);
    $product->cmsPosts; // Collection of CmsPost models

    Enforce Clean Type Names with Morph Maps

    By default, Laravel stores the full class name in linkable_type: App\Models\Product. If you ever refactor namespaces or rename models, those database values break.

    Fix this with Relation::enforceMorphMap() in a service provider:

    // app/Providers/AppServiceProvider.php
    use Illuminate\Database\Eloquent\Relations\Relation;
    
    public function boot()
    {
        Relation::enforceMorphMap([
            'product' => Product::class,
            'category' => Category::class,
            'tag' => Tag::class,
        ]);
    }

    Now linkable_type stores product instead of App\Models\Product. Your database is decoupled from your code structure.

    Converting to One-to-One Relationships

    If a model should only link to one CMS post, use ofOne() to convert the many-to-many into a one-to-one:

    public function cmsPost()
    {
        return $this->morphToMany(
            CmsPost::class,
            'linkable',
            'cms_links',
            'linkable_id',
            'cms_post_id'
        )->one();
    }

    Now $product->cmsPost returns a single CmsPost instance (or null), not a collection.

    Why This Pattern Wins

    • Clean domain models: No CMS-specific columns polluting your core tables.
    • Flexible: Need to link a new model? Just add the relationship—no migration to add columns.
    • Swappable: Migrating from WordPress to Contentful? Update the CmsPost model and the bridge table, your domain models stay untouched.
    • Testable: Mock the relationship, no need for the external CMS in tests.

    Real-World Example: Analytics Platform

    Suppose you’re tracking user actions in an external analytics platform. Each User, Order, and Event can have an analytics profile ID.

    // Migration
    Schema::create('analytics_links', function (Blueprint $table) {
        $table->id();
        $table->morphs('trackable');
        $table->string('analytics_profile_id');
        $table->timestamps();
        
        $table->unique(['trackable_id', 'trackable_type']);
    });
    
    // Models
    class User extends Model
    {
        public function analyticsProfile()
        {
            return $this->morphToMany(
                AnalyticsProfile::class,
                'trackable',
                'analytics_links',
                'trackable_id',
                'analytics_profile_id'
            )->one();
        }
    }
    
    // Usage
    $user->analyticsProfile()->attach($profileId);
    $profileId = $user->analyticsProfile->id;

    Your core models stay focused on your business logic. The analytics integration is isolated to the bridge table and the AnalyticsProfile model.

    When Not to Use This

    If the foreign ID is part of your domain—like user_id on an Order—put it directly in the table. This pattern is for external systems that your domain doesn’t inherently care about.

    But when you’re integrating with CMSs, analytics platforms, search engines, or any third-party system where multiple models need to link out, the polymorphic bridge pattern keeps your domain clean and your code flexible.

  • Draft-Then-Publish Pattern for Transactional Safety with External Systems

    When your Laravel app integrates with external systems—third-party APIs, CMS platforms via CLI, payment gateways—standard database transactions can’t protect you. If your Laravel transaction succeeds but the external call fails (or vice versa), you’re left with inconsistent state across systems.

    The solution: create external records in a draft or pending state, complete all Laravel operations within a transaction, and only promote the external resource to published/active after the transaction commits.

    The Pattern

    public function handle(): void {
        $createdExternalIds = [];
        
        foreach ($this->items as $item) {
            try {
                // Step 1: Create in external system as DRAFT
                $externalId = $this->createExternalResource($item, status: 'draft');
                $createdExternalIds[] = $externalId;
                
                // Step 2: Laravel DB transaction
                DB::transaction(function () use ($item, $externalId) {
                    $record = Record::create([
                        'external_id' => $externalId,
                        'title' => $item['title'],
                        'status' => 'pending'
                    ]);
                    
                    $record->tags()->sync($item['tags']);
                    // ... other database operations
                });
                
                // Step 3: Success! Publish the external resource
                $this->publishExternalResource($externalId);
                
            } catch (Throwable $e) {
                report($e);
                $this->error(sprintf('%s: %s', $item['title'], $e->getMessage()));
                continue; // Keep processing other items
            }
        }
    }

    Why This Works

    If the Laravel transaction fails for any reason—validation, constraint violation, or application error—the external record remains in draft state. You can clean it up later, retry the import, or manually investigate. Nothing went live that shouldn’t have.

    If the external API call succeeds but Laravel fails, you still have a draft record in the external system. Your database stays clean.

    Only when both sides succeed does the external record become visible to users.

    When to Use This

    • Content management systems: Create posts/pages as drafts, link them in Laravel, then publish.
    • Third-party APIs: If the API supports draft/pending states (Stripe payment intents, Shopify draft orders, etc.).
    • Batch imports: Processing hundreds or thousands of records where you want partial success rather than all-or-nothing.

    Implementation Tips

    1. Abstract the external calls into methods

    private function createExternalResource(array $data, string $status): string
    {
        $result = Http::post('https://api.example.com/resources', [
            'title' => $data['title'],
            'status' => $status,
        ]);
        
        return $result->json('id');
    }
    
    private function publishExternalResource(string $id): void
    {
        Http::patch("https://api.example.com/resources/{$id}", [
            'status' => 'published'
        ]);
    }

    2. Use continue on errors to process remaining items

    In batch operations, don’t let one failure kill the entire import. Log it, report it, move on.

    3. Consider cleanup jobs for orphaned drafts

    If your process crashes halfway through, you might have draft records in the external system with no corresponding Laravel record. Schedule a daily cleanup job that checks for drafts older than 24 hours and deletes them.

    Real-World Example: CMS Integration

    Let’s say you’re building a product catalog in Laravel that syncs to a headless CMS for public display. Each Laravel Product needs a corresponding CMS post.

    DB::transaction(function () use ($productData) {
        // Create CMS post as draft
        $cmsPostId = $this->cms->createPost([
            'title' => $productData['name'],
            'status' => 'draft',
            'content' => $productData['description']
        ]);
        
        // Create Laravel product
        $product = Product::create([
            'name' => $productData['name'],
            'sku' => $productData['sku'],
            'cms_post_id' => $cmsPostId,
        ]);
        
        $product->categories()->sync($productData['category_ids']);
        
        // Publish the CMS post
        $this->cms->publishPost($cmsPostId);
    });

    If anything inside the transaction fails—duplicate SKU, missing category, whatever—the CMS post stays as a draft. Your public site never shows broken data.

    When You Can’t Use Drafts

    Not every external system supports draft states. In those cases:

    • Do the Laravel work first, then make the external call after the transaction commits.
    • Use queued jobs for the external call—if it fails, retry logic kicks in.
    • Store the external state in a pending column in Laravel, update it to synced after success.

    But if the external system does support drafts or pending states, use them. It’s the cleanest way to maintain consistency across both systems.

  • Debugging Vue Component Show/Hide State

    The Problem

    You’re debugging a Vue component where clicking a button should toggle a panel’s visibility, but nothing happens. The button renders, the click handler fires, but the panel stays hidden (or visible). Where’s the disconnect?

    The Debug Checklist

    When Vue show/hide logic breaks, check these four spots in order:

    1. The State Variable Name

    Is your state variable named consistently? Check your data() function:

    data() {
      return {
        showAdvanced: false  // ← Is this the exact name you're using everywhere?
      }
    }

    Common mistake: defining showAdvanced but using show_advanced in the template.

    2. The Directive Reference

    Does your v-show or v-if reference the correct variable?

    <div v-show="showAdvanced" class="advanced-panel">
      <!-- content -->
    </div>

    Check for typos, wrong case (camelCase vs snake_case), or accidentally referencing a different variable.

    3. The Toggle Handler

    Is the button actually changing the state?

    <button @click="showAdvanced = !showAdvanced">
      Advanced Options
      <i :class="showAdvanced ? 'fa-angle-up' : 'fa-angle-down'"></i>
    </button>

    Use Vue DevTools to watch the state change in real-time. If the state toggles but the UI doesn’t update, you might have a reactivity issue.

    4. Parent-Child State Sharing

    If your toggle button is in one component and the panel is in another, are they sharing state correctly?

    Bad: Each component has its own local copy of showAdvanced

    Good: Use props down, $emit up, or shared state (Vuex/Pinia)

    Full Working Example

    <template>
      <div>
        <button @click="showAdvanced = !showAdvanced" class="btn btn-secondary">
          {{ showAdvanced ? 'Hide' : 'Show' }} Advanced Options
          <i :class="showAdvanced ? 'fa-angle-up' : 'fa-angle-down'"></i>
        </button>
        
        <transition name="fade">
          <div v-show="showAdvanced" class="advanced-panel mt-3">
            <!-- Your advanced options here -->
            <div class="form-group">
              <label>Advanced Setting 1</label>
              <input type="text" class="form-control">
            </div>
            <div class="form-group">
              <label>Advanced Setting 2</label>
              <input type="text" class="form-control">
            </div>
          </div>
        </transition>
      </div>
    </template>
    
    <script>
    export default {
      data() {
        return {
          showAdvanced: false
        }
      }
    }
    </script>
    
    <style scoped>
    .fade-enter-active, .fade-leave-active {
      transition: opacity 0.3s;
    }
    .fade-enter, .fade-leave-to {
      opacity: 0;
    }
    </style>

    Why This Pattern Works

    • Single source of truth: One state variable controls everything
    • Clear naming: showAdvanced is unambiguous
    • Tight coupling: Button and panel reference the same variable
    • Visual feedback: Icon changes to reflect state

    Pro Tip: Use Vue DevTools

    Install the Vue DevTools browser extension and watch your component’s state in real-time. When you click the button, you should see showAdvanced flip from false to true. If it doesn’t, your click handler isn’t working. If it does but the UI doesn’t update, you have a reactivity problem.

    Next time your Vue show/hide logic breaks, work through this checklist. Nine times out of ten, it’s a variable name mismatch or a missing reactivity binding.

  • Debugging 403 API Errors: When Authorization Headers Go Wrong

    Your Laravel app has been calling a third-party API for months. Suddenly: 403 Forbidden.

    Error message: “Invalid key=value pair (missing equal-sign) in Authorization header”.

    Nothing changed on your side. What gives?

    The Problem

    403 errors are often subtle formatting issues, not permission problems.

    APIs expect Authorization headers in exact formats. One missing space, one wrong prefix, and you’re locked out.

    Debugging Steps

    Step 1: Log the Actual Header Being Sent

    Don’t assume your code is building it correctly. Log it.

    use Illuminate\Support\Facades\Http;
    use Illuminate\Support\Facades\Log;
    
    $token = config('services.external_api.token');
    
    Http::withHeaders([
            'Authorization' => 'Bearer ' . $token,
        ])
        ->beforeSending(function ($request) {
            Log::info('API Request Headers', [
                'authorization' => $request->header('Authorization'),
            ]);
        })
        ->post($apiUrl, $data);
    

    Step 2: Compare with API Docs

    Check for recent updates. APIs change auth schemes without warning (or bury it in a changelog).

    Step 3: Verify Format

    Is it Bearer? Basic? Custom scheme?

    Step 4: Check for Hidden Characters

    Extra whitespace, newlines, URL-encoding.

    Common Causes

    1. Missing ‘Bearer’ Prefix

    // ❌ Wrong
    'Authorization' => $token
    
    // ✅ Right
    'Authorization' => 'Bearer ' . $token
    

    2. Concatenation Without Space

    // ❌ Wrong (missing space after Bearer)
    'Authorization' => 'Bearer' . $token
    
    // ✅ Right
    'Authorization' => 'Bearer ' . $token
    

    3. Double-Encoding or URL-Encoding

    // ❌ Wrong (token got URL-encoded somehow)
    Authorization: Bearer abc%20123
    
    // ✅ Right
    Authorization: Bearer abc123
    

    4. API Changed Auth Scheme

    Old format (deprecated):

    Authorization: ApiKey abc123
    

    New format (required):

    Authorization: Bearer abc123
    

    Solution: Check API changelog, update to new format.

    Real-World Example

    An API I integrated with suddenly started rejecting requests. Logs showed:

    Sent: "Authorization: Bearer abc123xyz"
    Expected: "Authorization: Bearer abc123xyz" (identical!)
    

    Turned out: API updated validation to reject trailing whitespace. My token had \n at the end from .env.

    Fix:

    $token = trim(config('services.external_api.token'));
    

    Debugging Checklist

    1. Log the exact header string being sent
    2. Compare byte-by-byte with API docs
    3. Check for extra whitespace (leading/trailing)
    4. Verify prefix/scheme matches API requirements
    5. Test with curl to isolate Laravel vs API issue

    Testing with curl

    curl -X POST https://api.example.com/endpoint \
      -H "Authorization: Bearer YOUR_TOKEN_HERE" \
      -H "Content-Type: application/json" \
      -d '{"test": true}' \
      -v
    

    If curl works but Laravel doesn’t, the issue is in your header construction.

    The Takeaway

    403 errors ≠ permission denied. Often it’s formatting.

    • Log the actual header, don’t assume
    • Compare byte-by-byte with API docs
    • Check for hidden characters (whitespace, newlines)
    • Test with curl to isolate the issue

    One missing space can cost you hours of debugging. Log first, guess later.

  • Vue.js Gotcha: Single-Option Dropdowns Don’t Trigger v-model Updates

    You’ve built a beautiful form. User selects “Project Manager” from a dropdown. Form submits. Backend throws: role_id is required.

    What happened? The dropdown had only one option. Vue’s v-model never fired.

    The Problem

    Vue’s v-model on <select> elements only updates when a @change event fires.

    Single-option dropdowns never fire @change because the user can’t change anything.

    What the User Sees

    <select v-model="form.role_id">
      <option value="3">Project Manager</option>
    </select>
    

    UI shows: “Project Manager” (looks selected).

    Reality: form.role_id = null.

    Why It Breaks

    1. Component initializes with form.role_id = null
    2. Dropdown renders with one option
    3. Browser displays that option as “selected” (visual default)
    4. No user interaction = no @change event
    5. v-model never updates
    6. Form submits null

    The Solutions

    Option 1: Auto-Select in Lifecycle Hook (Recommended)

    mounted() {
      // Auto-select if only one option
      if (this.roleOptions.length === 1) {
        this.form.role_id = this.roleOptions[0].id;
      }
    }
    

    Pros: Clean, explicit, works with any UI library.

    Cons: Needs lifecycle hook in every component with single-option dropdowns.

    Option 2: Backend Defensive Handling

    public function assignRole(Request $request)
    {
        // Fallback to default if frontend didn't send value
        $roleId = $request->input('role_id') 
            ?? Role::getDefaultForContext($request->input('department_id'));
        
        // Validate the final value
        $request->merge(['role_id' => $roleId]);
        $validated = $request->validate([
            'role_id' => 'required|exists:roles,id',
        ]);
        
        // ... proceed with assignment
    }
    

    Pros: Fails gracefully, works even if frontend changes.

    Cons: Backend needs context to know which default to use.

    Option 3: Disable Dropdown When Single Option

    <select 
      v-model="form.role_id" 
      :disabled="roleOptions.length === 1"
    >
      <option 
        v-for="role in roleOptions" 
        :key="role.id" 
        :value="role.id"
      >
        {{ role.name }}
      </option>
    </select>
    

    Pros: Visual clarity that choice is locked.

    Cons: Still need lifecycle hook or backend fallback.

    Recommended Approach: Defense in Depth

    Combine Option 1 + Option 2:

    1. Frontend: Auto-select in mounted()
    2. Backend: Defensive null handling with context-aware defaults

    This way, if the frontend breaks (JS error, race condition, browser quirk), the backend still handles it gracefully.

    The Lesson

    Never trust frontend validation alone. Single-option dropdowns are a UI/UX trap—they look selected but aren’t.

    Always validate and default on the backend. Your future self (and your error logs) will thank you.

  • Eloquent Aggregates in HAVING Clauses for Complex Partner Filtering

    When building reports with complex partner relationship logic, you can use selectRaw() with aggregate functions combined with havingRaw() to filter results based on multiple relationship conditions.

    Instead of loading all relationships into memory and filtering in PHP, let the database do the heavy lifting by calculating relationship counts in your SELECT and filtering in HAVING.

    This is especially powerful when you need to check:

    • “Is this exclusive TO this partner?” (count exclusive = 1)
    • “Is this excluded FROM this partner?” (count excluded < 1)
    • “Is this exclusive to ANYONE?” (count exclusive for others > 0)
    // Count partner relationships at different levels
    $query->selectRaw(
        'count(if(partners.excluded = 0 AND partners.org_id = ?, 1, null)) as is_exclusive_for_me',
        [$currentOrgId]
    )
    ->selectRaw(
        'count(if(partners.excluded = 1 AND partners.org_id = ?, 1, null)) as is_excluded_from_me',
        [$currentOrgId]
    )
    ->selectRaw(
        'count(if(partners.excluded = 0, 1, null)) as exclusive_for_anyone'
    )
    ->havingRaw(
        '(is_exclusive_for_me >= 1 OR (is_excluded_from_me < 1 AND exclusive_for_anyone < 1))'
    );
  • Content Whitelist Pattern: Handle Empty Lists with Explicit Intent

    When implementing content whitelists with opt-in flags, always handle the empty whitelist case explicitly. An empty whitelist combined with a “whitelist required” flag should return NO results, not bypass the filter.

    This pattern prevents accidental data leakage when a partner has whitelist requirements but hasn’t configured items yet. Match your internal reports to your API behavior – if the API returns nothing, the report should too.

    if ($partner->getContentWhitelist()->isEmpty()) {
        if ($partner->requiresWhitelist()) {
            // Empty whitelist + requirement = return nothing
            return $query->whereIn('products.id', []);
        }
        // No requirement, skip filtering
        return $query;
    }
    
    // Has whitelist items, apply filter
    return $query->whereIn(
        'products.id',
        $partner->getContentWhitelist()->pluck('product_id')->toArray()
    );
  • Prevent Sentry Noise by Normalizing External API Errors

    When integrating with third-party APIs that return dynamic error references, you can end up with dozens of fragmented Sentry issues for the same underlying problem. The fix: catch the exception, parse the actual error code, and rethrow using your own exception type.

    The Problem: External APIs often include unique request IDs or timestamps in error messages. Sentry treats each variation as a separate issue, creating noise.

    The Solution: Extract the stable error code from the response body and use it to decide how to handle the exception.

    This groups all occurrences under a single Sentry issue and lets you apply domain-specific handling (like stopping retries for permanently unavailable resources).

    // Before: Each API error creates a separate Sentry issue
    $product = $apiClient->getProduct($id);
    
    // After: Parse error code and rethrow normalized exception
    try {
        $product = $apiClient->getProduct($id);
    } catch (ApiException $e) {
        $errorCode = json_decode(
            (string) $e->getPrevious()?->getResponse()->getBody(),
            true
        )['error'] ?? null;
    
        if (in_array($errorCode, ['PRODUCT_EXPIRED', 'PRODUCT_DISABLED'])) {
            // Throw domain exception that stops retries
            throw new InvalidResourceException(
                resourceId: $id,
                previous: $e
            );
        }
    
        throw $e; // Re-throw other errors as-is
    }
  • Laravel’s Hidden Safety Net: What Happens When Cache TTL is Zero

    Laravel’s Hidden Safety Net: What Happens When Cache TTL is Zero

    While working on caching implementation for an API client, I discovered an interesting Laravel behavior that isn’t widely documented: what happens when you try to cache data with a TTL of zero or less?

    The Discovery

    I had code that looked like this:

    $ttl = $this->authSession->getRemainingTtl(); // Could be 0 if expired
    $this->cache->put($cacheKey, $result, $ttl);
    

    My concern was: what if getRemainingTtl() returns 0 because the auth session expired? Would Laravel:

    • Store the data anyway (bad – invalid cache)
    • Throw an exception (annoying but safe)
    • Silently ignore it (confusing)
    • Do something smarter?

    The Answer: Laravel Deletes The Key

    Diving into vendor/laravel/framework/src/Illuminate/Cache/Repository.php, lines 197-220:

    public function put($key, $value, $ttl = null)
    {
        if (is_array($key)) {
            return $this->putMany($key, $value);
        }
    
        if ($ttl === null) {
            return $this->forever($key, $value);
        }
    
        $seconds = $this->getSeconds($ttl);
    
        if ($seconds <= 0) {
            return $this->forget($key);  // ← The magic line
        }
    
        $result = $this->store->put($this->itemKey($key), $value, $seconds);
    
        if ($result) {
            $this->event(new KeyWritten($key, $value, $seconds));
        }
    
        return $result;
    }
    

    When TTL ≤ 0, Laravel calls forget($key) – it actively deletes the cache entry.

    Why This Is Brilliant

    This behavior prevents a class of bugs:

    • If the key doesn’t exist yet → nothing happens (safe, no-op)
    • If the key already exists → it gets deleted (removes stale data)
    • No invalid cache entries with expired TTL ever get stored

    This means you don’t need defensive code like:

    // ❌ Unnecessary guard
    if ($ttl > 0) {
        $this->cache->put($cacheKey, $result, $ttl);
    }
    

    You can safely do:

    // ✅ Laravel handles it gracefully
    $this->cache->put($cacheKey, $result, $this->authSession->getRemainingTtl());
    

    Practical Applications

    This is particularly useful when caching API responses with dynamic TTLs tied to auth tokens:

    public function fetchData(string $endpoint): array
    {
        $cacheKey = "api:{$endpoint}";
    
        $cached = $this->cache->get($cacheKey);
        if ($cached) {
            return $cached;
        }
    
        $this->ensureAuthenticated();
        $data = $this->request('GET', $endpoint);
    
        // Cache expires when auth token expires
        // If token is already expired (TTL = 0), Laravel auto-deletes the key
        $this->cache->put(
            $cacheKey,
            $data,
            $this->authToken->getExpiresIn()
        );
    
        return $data;
    }
    

    The Takeaway

    Laravel’s cache implementation has thoughtful edge-case handling. When you pass TTL ≤ 0 to put(), it doesn’t fail or store invalid data – it actively cleans up by deleting the key. This makes caching with dynamic TTLs safer and requires less defensive code.

    Small framework details like this add up to more robust applications.

  • Hybrid State Management: Database Triggers + Eloquent Casts

    For complex state machines involving multiple boolean flags (like is_canceled, is_on_hold, etc.), using MySQL triggers to compute a single status column is a powerful pattern. This approach prevents data inconsistencies while keeping database queries fast and efficient.

    The Problem: Managing Competing States

    If an order has is_canceled = 1 and is_on_hold = 0, what should the status be? If you compute this logic in PHP every time you query the record, it adds overhead. Storing it redundantly in the database can lead to “drift” where the status column becomes out of sync with the boolean flags.

    The Solution: MySQL Triggers

    By using a database trigger, you can automatically compute the correct status based on the boolean flags whenever a record is inserted or updated. This ensures that your source of truth (the flags) is always reflected in your queryable column (the status).

    Implementation

    // Migration
    Schema::create('orders', function (Blueprint $table) {
        $table->id();
        $table->boolean('is_canceled')->default(false);
        $table->boolean('is_on_hold')->default(false);
        $table->boolean('is_archived')->default(false);
        $table->string('status')->default('active'); // Auto-computed column
        $table->datetime('status_changed_at')->nullable();
        $table->timestamps();
    });
    
    // Create trigger in migration
    DB::unprepared("
        CREATE TRIGGER orders_status_update BEFORE UPDATE ON orders
        FOR EACH ROW
        BEGIN
            DECLARE status_val VARCHAR(50);
            
            SET status_val = CASE
                WHEN NEW.is_canceled = 1 THEN 'canceled'
                WHEN NEW.is_on_hold = 1 THEN 'on_hold'
                WHEN NEW.is_archived = 1 THEN 'archived'
                ELSE 'active'
            END;
            
            IF NEW.status != status_val THEN
                SET NEW.status = status_val;
                SET NEW.status_changed_at = NOW();
            END IF;
        END
    ");
    

    Leveraging in Laravel

    Your business logic can continue using simple, expressive boolean flags while remaining confident that the queryable status is always correct.

    class Order extends Model
    {
        protected $casts = [
            'status' => OrderStatus::class, // Backed Enum
            'is_canceled' => 'boolean',
            'is_on_hold' => 'boolean',
        ];
        
        public function cancel(): void
        {
            $this->is_canceled = true;
            $this->save();
            // The MySQL trigger automatically sets status='canceled'
            // and status_changed_at=NOW()
        }
    }
    

    This hybrid approach combines the best of both worlds: highly expressive business logic in PHP and consistent, high-performance querying at the database layer.