Category: Laravel

  • Laravel 12: Adding API Routes the Official Way

    Laravel 12 removed routes/api.php from the default skeleton to keep new projects lean. When you’re ready to build APIs, here’s the modern way to add API support.

    The Old Way (Laravel 10 and Earlier)

    Every fresh Laravel install came with routes/api.php pre-registered with the /api prefix. Most projects never used it, but it was always there.

    The Laravel 12 Way

    Run one command:

    php artisan install:api
    

    This installs:

    • routes/api.php with automatic /api/* prefix
    • Route registration in bootstrap/app.php
    • Laravel Sanctum for token-based authentication
    • Sanctum database migrations

    After running it, any route you add to routes/api.php is automatically accessible at /api/{route}.

    What Gets Generated

    Inside bootstrap/app.php, you’ll see:

    return Application::configure(basePath: dirname(__DIR__))
        ->withRouting(
            web: __DIR__.'/../routes/web.php',
            api: __DIR__.'/../routes/api.php',  // ← Added by install:api
            commands: __DIR__.'/../routes/console.php',
            health: '/up',
        )
        // ...
    

    It’s the same structure as before, just opt-in instead of default.

    Manual Alternative (No Sanctum)

    If you don’t need Sanctum (e.g., public APIs or custom auth), you can manually add API routes without installing the full package:

    // bootstrap/app.php
    return Application::configure(basePath: dirname(__DIR__))
        ->withRouting(
            web: __DIR__.'/../routes/web.php',
            api: __DIR__.'/../routes/api.php',  // ← Add manually
            commands: __DIR__.'/../routes/console.php',
            health: '/up',
        )
    

    Then create routes/api.php yourself:

    <?php
    
    use Illuminate\Support\Facades\Route;
    
    Route::get('/status', function () {
        return ['status' => 'ok'];
    });
    

    Routes in this file are automatically prefixed with /api.

    Why the Change?

    Most Laravel apps don’t need API routes. Removing them from the skeleton reduces decision fatigue for beginners and clutter for everyone else.

    When you do need APIs, install:api gives you the full setup in one step instead of hunting for documentation.

    Bottom line: Laravel 12 defaults to simple, adds complexity on demand.

  • Tracing Frontend Form Fields to Backend Database Columns

    A user reports “the status field isn’t saving.” You need to find where that field is stored in the database. Here’s the fastest way to trace from frontend label to backend column in a Laravel + Vue.js app.

    The Workflow

    1. Start with the label text

    User says “status field.” Search your Vue components for that label:

    grep -r "Status" resources/js/components/

    Find the component:

    <template>
      <div>
        <label>Status</label>
        <select v-model="form.order_status">
          <option value="pending">Pending</option>
          <option value="completed">Completed</option>
        </select>
      </div>
    </template>

    2. Check the v-model binding

    v-model="form.order_status" tells you the property name. Check what gets sent to the backend:

    // In the same component or parent
    methods: {
      submitForm() {
        axios.post('/api/orders', this.form)
          .then(/* ... */)
      }
    }

    The POST payload includes order_status: 'pending'.

    3. Match to the Laravel controller

    Check your API route. The request hits OrderController@store:

    public function store(Request $request)
    {
        $validated = $request->validate([
            'order_status' => 'required|string',
            // ...
        ]);
    
        Order::create($validated);
    }

    4. Check the database column

    In most Laravel apps, the input name matches the column name. Verify in your migration or model:

    Schema::create('orders', function (Blueprint $table) {
        $table->id();
        $table->string('order_status');  // ← Found it
        // ...
    });

    Full path: Status label → form.order_status (Vue) → order_status (request) → order_status (column)

    When Names Don’t Match

    Sometimes frontend and backend use different names:

    <!-- Frontend: display_name -->
    <input v-model="form.display_name">
    // Backend renames it
    public function store(Request $request)
    {
        User::create([
            'name' => $request->input('display_name'),
        ]);
    }

    Check $fillable or create() calls to see the mapping.

    Bonus: Use Browser DevTools

    Open Network tab, submit the form, inspect the POST payload. You’ll see exactly what key names are sent:

    {
      "order_status": "pending",
      "customer_email": "[email protected]"
    }

    Match those keys to your database columns.

    Why This Matters

    When debugging form issues, you need the column name to:

    • Check database constraints (NOT NULL, unique, etc.)
    • Read logs filtered by that field
    • Write queries to inspect actual data
    • Verify fillable/guarded settings

    This workflow gets you from “the status field” (user language) to orders.order_status (database reality) in under a minute.

  • Markdown Links in Database Comments for Better Admin UX

    Database comment fields are often filled with plain reference IDs that require manual copying and pasting into search bars or URLs. Here’s a tiny UX improvement that turns those plain IDs into clickable links.

    The Problem

    Your admin panel lets users add notes to records. Users often reference related resources by ID:

    // Admin adds comment like this:
    "Related to order #12345"
    "See invoice #67890 for details"

    Later, someone reads that comment and has to manually navigate to the orders page, search for 12345, etc. Wasted clicks.

    The Solution: Markdown Links

    Store markdown-formatted links in your comment field instead:

    // When saving the comment:
    $comment = sprintf(
        '[Order #%d](%s)',
        $order->id,
        route('admin.orders.show', $order)
    );
    
    $record->notes = $comment;
    $record->save();

    Result in the database:

    [Order #12345](/admin/orders/12345)

    Display It in the UI

    Most admin panels render comments as plain text. Parse it with a markdown library on display:

    use League\CommonMark\CommonMarkConverter;
    
    $converter = new CommonMarkConverter();
    $html = $converter->convert($record->notes);
    
    // In your Blade template:
    {!! $html !!}

    Now your admins see clickable links instead of plain IDs.

    When To Use This

    • Audit logs: Link to the user who made the change
    • Error reports: Link to the transaction or session that failed
    • Notes fields: Link to related records
    • Status changes: Link to the approval or rejection action

    Quick Wins

    1. Start with one high-traffic admin page
    2. Add markdown rendering to the comments display
    3. Wrap IDs in markdown links when creating new comments
    4. Watch your team stop asking “where do I find order 12345?”

    Small change. Big quality of life improvement.

  • Exponential Backoff: Why attempts⁴ and attempts⁵ Are Not the Same

    Exponential Backoff: Why attempts⁴ and attempts⁵ Are Not the Same

    Your queue job hits a rate limit. You need exponential backoff. But which formula?

    Here are two real patterns I’ve seen coexist in the same codebase — and the differences matter more than you’d think.

    Pattern A: attempts⁴ + Jitter

    catch (RateLimitedException $e) {
        $this->release(($this->attempts() ** 4) + random_int(0, 40));
    }

    Pattern B: Base Delay + attempts⁵

    protected function getDelay(): int
    {
        return 30 + $this->attempts() ** 5;
    }
    
    catch (RateLimitedException $e) {
        $this->release($this->getDelay());
    }

    The Numbers Tell the Story

    Let’s calculate the actual delays:

    Pattern A (attempts⁴ + random(0, 40)):

    • Attempt 1: 1–41 seconds
    • Attempt 2: 16–56 seconds
    • Attempt 3: 81–121 seconds (~2 min)
    • Attempt 4: 256–296 seconds (~5 min)
    • Attempt 5: 625–665 seconds (~11 min)

    Pattern B (30 + attempts⁵):

    • Attempt 1: 31 seconds
    • Attempt 2: 62 seconds
    • Attempt 3: 273 seconds (~4.5 min)
    • Attempt 4: 1,054 seconds (~17.5 min)
    • Attempt 5: 3,155 seconds (~52 min)

    When to Use Which

    Pattern A is for user-facing work. Order processing, payment confirmations, notification delivery — anything where a customer is waiting. The lower exponent (⁴ vs ⁵) keeps retry times reasonable. The random jitter prevents thundering herd problems when dozens of jobs fail simultaneously and would otherwise all retry at the exact same second.

    Pattern B is for background synchronization. Data imports, inventory syncs, report generation — work that can wait. The higher exponent (⁵) creates a much steeper curve that backs off aggressively. The 30-second base floor means you never retry immediately, giving the remote service real breathing room.

    Why Jitter Matters

    Pattern A’s random_int(0, 40) isn’t cosmetic. Without it, if 50 jobs hit a rate limit at the same time, they all retry at exactly attempts⁴ seconds later — probably hitting the rate limit again. Jitter spreads them across a 40-second window.

    Pattern B skips jitter because background jobs are already spread out. They don’t pile up the same way user-triggered actions do.

    The Takeaway

    Don’t just copy the first backoff formula you find. Think about:

    1. Who’s waiting? Users → lower exponent, faster retries
    2. How many concurrent failures? Many → add jitter
    3. How sensitive is the API? Very → higher exponent, base delay floor

    Pick the curve that matches your use case. One size doesn’t fit all, even within the same application.

  • Laravel Queued Event Listeners Need InteractsWithQueue for Retries

    Laravel Queued Event Listeners Need InteractsWithQueue for Retries

    You implement ShouldQueue on your event listener, add a catch block for rate limit exceptions, and write $this->release(60) to retry after a delay. Looks right. Ship it.

    Then it blows up in production: “Call to undefined method release()”.

    The Missing Piece

    When a Laravel queued event listener implements ShouldQueue, it gets wrapped in a CallQueuedListener job under the hood. But the listener class itself doesn’t automatically get queue interaction methods like release(), attempts(), or delete().

    You need the InteractsWithQueue trait:

    use Illuminate\Contracts\Queue\ShouldQueue;
    use Illuminate\Queue\InteractsWithQueue;
    
    class ProcessOrderUpdate implements ShouldQueue
    {
        use InteractsWithQueue;
    
        public function handle(OrderStatusChanged $event): void
        {
            try {
                $this->processor->process($event->order);
            } catch (RateLimitedException $e) {
                // Without InteractsWithQueue, this line crashes
                $this->release(($this->attempts() ** 4) + random_int(0, 40));
            }
        }
    }

    Why It’s Easy to Miss

    Queued jobs (extending Illuminate\Bus\Queueable) typically include InteractsWithQueue in their boilerplate. It’s so common you never think about it. But queued event listeners are a different class hierarchy — they implement the interface without the trait.

    The confusing part: everything works without the trait until you try to call release() or attempts(). Your listener queues fine, executes fine, and only fails when you need retry control.

    The Fix

    Simple rule: if your queued listener needs to control its own retry behavior — releasing back to the queue, checking attempt count, or deleting itself — add use InteractsWithQueue.

    Without it, you have a listener that can be queued but can’t manage its own lifecycle. With it, you get the full toolkit:

    • $this->release($delay) — put it back on the queue
    • $this->attempts() — how many times it’s been tried
    • $this->delete() — remove it from the queue

    Check your queued listeners. If any of them catch exceptions and try to retry, make sure they have the trait. The error won’t surface until that catch block actually executes — which is usually production, under load, at the worst possible time.

  • Exception Hierarchies: Control Laravel Queue Retry Behavior

    Here’s a scenario every Laravel developer hits eventually: your queue job integrates with a third-party API, and that API starts returning errors. Your job dutifully retries 5 times, backs off, and eventually fails. Multiply that by thousands of jobs, and suddenly your queue is clogged with doomed retries.

    The problem? Not all errors deserve retries.

    The Setup

    Imagine a job that syncs inventory from an external REST API:

    class SyncInventoryJob implements ShouldQueue
    {
        public $tries = 5;
        public $backoff = [10, 30, 60, 120, 300];
    
        public function handle()
        {
            try {
                $response = $this->apiClient->fetchInventory($this->productId);
                $this->processResponse($response);
            } catch (RateLimitedException $e) {
                $this->release($e->retryAfter());
            } catch (TemporarilyUnavailableException $e) {
                $this->delete();
                Log::warning("API unavailable for product {$this->productId}, removing from queue");
            } catch (\Exception $e) {
                // Generic retry — but should we?
                throw $e;
            }
        }
    }

    The catch blocks form a hierarchy: rate-limited gets a smart release, temporarily unavailable gets deleted, and everything else retries via the default mechanism.

    The Bug

    The API client was throwing a generic ApiException for every failure — including vendor-side database errors that would never self-resolve. These fell through to the generic \Exception catch, triggering 5 retries each.

    With 4,000 affected products, that’s 20,000 wasted queue attempts hammering a broken endpoint.

    The Fix

    Map vendor-side errors to your existing exception hierarchy:

    class ApiClient
    {
        public function fetchInventory(string $productId): array
        {
            $response = Http::get("{$this->baseUrl}/inventory/{$productId}");
    
            if ($response->status() === 429) {
                throw new RateLimitedException(
                    retryAfter: $response->header('Retry-After', 60)
                );
            }
    
            if ($response->serverError()) {
                $body = $response->body();
    
                // Vendor-side errors that WE can't fix
                if (str_contains($body, 'Internal Server Error')
                    || str_contains($body, 'database timeout')) {
                    throw new TemporarilyUnavailableException(
                        "Vendor-side error: {$response->status()}"
                    );
                }
            }
    
            if ($response->failed()) {
                throw new ApiException("API error: {$response->status()}");
            }
    
            return $response->json();
        }
    }

    The key insight: TemporarilyUnavailableException already existed in the job’s catch hierarchy. We just needed the API client to throw it for the right situations. No job changes required.

    The Pattern

    Design your exception hierarchy around what the caller should do, not what went wrong:

    • RateLimitedException → release with delay (their problem, temporary)
    • TemporarilyUnavailableException → delete, don’t retry (their problem, not our concern)
    • InvalidConfigException → fail permanently (our problem, needs code fix)
    • Generic Exception → retry with backoff (unknown, worth trying)

    When a new error type appears, you don’t change the job — you classify it into the right exception type at the source.

    The takeaway: Exception hierarchies in queue jobs aren’t just about catching errors. They’re a retry policy DSL. Each exception class encodes a decision about what to do next. Make that decision at the API client level, and your jobs stay clean.

  • The SerializesModels Trap: Why Your Laravel Job Retries Never Run

    If you’ve ever set $tries and $backoff on a Laravel queue job and wondered why they’re completely ignored when a model goes missing, you’ve hit the SerializesModels trap.

    The Problem

    When a job uses the SerializesModels trait, Laravel stores just the model’s ID in the serialized payload. When the job gets picked up by a worker, Laravel calls firstOrFail() to restore the model before your handle() method ever runs.

    If that model was deleted between dispatch and execution, firstOrFail() throws a ModelNotFoundException. This exception happens during deserialization — outside the retry/backoff lifecycle entirely. Your carefully configured retry logic never gets a chance to run.

    class ProcessOrder implements ShouldQueue
    {
        use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    
        public $tries = 5;
        public $backoff = [10, 30, 60];
    
        public function __construct(
            public Order $order  // Serialized as just the ID
        ) {}
    
        public function handle()
        {
            // This never executes if the Order was deleted.
            // $tries and $backoff are completely bypassed.
        }
    }

    Why It Happens

    The model restoration happens in the RestoreModel class, which uses firstOrFail(). This is called by PHP’s __wakeup() / unserialize() pipeline — way before Laravel’s retry middleware kicks in. The job fails immediately with zero retries.

    The Fix: A Nullable Models Trait

    Create a custom trait that returns null instead of throwing when a model is missing:

    trait SerializesNullableModels
    {
        use SerializesModels {
            SerializesModels::__serialize as parentSerialize;
            SerializesModels::__unserialize as parentUnserialize;
        }
    
        public function __unserialize(array $values): void
        {
            try {
                $this->parentUnserialize($values);
            } catch (ModelNotFoundException $e) {
                // Set the property to null instead of exploding
                // Your handle() method can then check for null
            }
        }
    }

    Then in your handle() method:

    public function handle()
    {
        if ($this->order === null) {
            // Model was deleted — release for retry or handle gracefully
            $this->release(30);
            return;
        }
    
        // Normal processing...
    }

    The Takeaway

    SerializesModels is convenient, but it creates a blind spot in your retry logic. If there’s any chance your model might be deleted between dispatch and execution — webhook jobs, async processing after user actions, anything with eventual consistency — either use the nullable trait pattern or pass the ID manually and look it up yourself in handle().

    Your $tries config only works when the exception happens inside handle(). Everything before that is a different world.

  • When Queued Event Listeners Silently Die: The ShouldQueue Trap

    You dispatch an event from inside a queued job. The event has a listener that implements ShouldQueue. Your job completes successfully, but the listener never executes. No exception. No failed job entry. No log. It just… doesn’t run.

    This is one of Laravel’s most frustrating silent failures.

    The Setup

    You have a workflow: when a user account is deactivated, trigger a data export automatically. The architecture looks clean:

    // In your DeactivationHandler (queued job)
    class HandleAccountDeactivation implements ShouldQueue
    {
        public function handle(): void
        {
            // Revoke access tokens for the account
            $this->revokeAccessTokens($this->account);
    
            // Dispatch event for downstream processing
            event(new AccountDeactivated($this->account));
        }
    }
    
    // The listener
    class TriggerDataExport implements ShouldQueue
    {
        public function handle(AccountDeactivated $event): void
        {
            // This never runs!
            $this->exportService->generate($event->account);
        }
    }

    Why It Fails Silently

    When you dispatch an event from within a queued job, and the listener also implements ShouldQueue, the listener gets pushed onto the queue as a new job. But here’s the catch: if the dispatching job’s database transaction hasn’t committed yet (or if the queue connection has issues during nested dispatching), the listener job can fail before it even starts — and this failure happens at the queue infrastructure level, not in your application code.

    A try-catch around event() won’t help. The event dispatch itself succeeds — it pushes a message onto the queue. The failure happens later, when the queue worker tries to process the listener job.

    The Fix: Make Critical Listeners Synchronous

    For listeners that are part of a critical workflow — where silent failure is unacceptable — remove ShouldQueue:

    // Make it synchronous — runs in the same process as the dispatcher
    class TriggerDataExport // No ShouldQueue
    {
        public function handle(AccountDeactivated $event): void
        {
            try {
                $this->exportService->generate($event->account);
            } catch (\Throwable $e) {
                // Now you CAN catch failures
                $event->account->addNote(
                    "Automatic data export failed: {$e->getMessage()}"
                );
                $event->account->flagForReview('compliance');
            }
        }
    }

    Alternative: Direct Method Calls for Critical Paths

    If the listener exists solely because of one dispatcher, skip events entirely for the critical path:

    class HandleAccountDeactivation implements ShouldQueue
    {
        public function handle(DataExportService $exportService): void
        {
            $this->revokeAccessTokens($this->account);
    
            // Direct call instead of event dispatch
            try {
                $exportService->generateComplianceExport($this->account);
            } catch (\Throwable $e) {
                $this->account->addNote("Automatic data export failed: {$e->getMessage()}");
                $this->account->flagForReview('compliance');
            }
        }
    }

    When Events Are Still Right

    Events shine when:

    • Multiple independent listeners react to the same event
    • The listener’s failure doesn’t affect the main workflow
    • You genuinely need decoupling (different bounded contexts)

    But when a queued job dispatches an event to a queued listener for a single critical operation? That’s a fragile chain with a silent failure mode. Make it synchronous or call the service directly.

    The rule of thumb: if the listener failing means the workflow is broken, don’t put a queue boundary between them.

  • Circuit Breakers: Stop Hammering Dead APIs From Your Queue Workers

    Your queue workers are burning through jobs at full speed, retrying a third-party API endpoint that’s been down for three hours. Every retry fails. Every failure generates a Sentry alert. You’re 55,000 errors deep, your queue is backed up, and the external service doesn’t care how many times you knock.

    This is what happens when you don’t have a circuit breaker.

    The Pattern

    A circuit breaker sits between your application and an unreliable external service. It tracks failures and, after a threshold, stops sending requests entirely for a cooldown period. The metaphor comes from electrical engineering — when there’s too much current, the breaker trips to prevent damage.

    Three states:

    • Closed — everything works normally, requests flow through
    • Open — too many failures, all requests short-circuit immediately (return error without calling the API)
    • Half-Open — after cooldown, let one request through to test if the service recovered

    A Simple Implementation

    class CircuitBreaker
    {
        public function __construct(
            private string $service,
            private int $threshold = 5,
            private int $cooldownSeconds = 300,
        ) {}
    
        public function isAvailable(): bool
        {
            $failures = Cache::get("circuit:{$this->service}:failures", 0);
            $openedAt = Cache::get("circuit:{$this->service}:opened_at");
    
            if ($failures < $this->threshold) {
                return true; // Closed state
            }
    
            if ($openedAt && now()->diffInSeconds($openedAt) > $this->cooldownSeconds) {
                return true; // Half-open: try one request
            }
    
            return false; // Open: reject immediately
        }
    
        public function recordFailure(): void
        {
            $failures = Cache::increment("circuit:{$this->service}:failures");
    
            if ($failures >= $this->threshold) {
                Cache::put("circuit:{$this->service}:opened_at", now(), $this->cooldownSeconds * 2);
            }
        }
    
        public function recordSuccess(): void
        {
            Cache::forget("circuit:{$this->service}:failures");
            Cache::forget("circuit:{$this->service}:opened_at");
        }
    }

    Using It in a Queue Job

    class FetchWeatherDataJob implements ShouldQueue
    {
        public function handle(WeatherApiClient $client): void
        {
            $breaker = new CircuitBreaker('weather-api', threshold: 5, cooldownSeconds: 300);
    
            if (! $breaker->isAvailable()) {
                // Release back to queue for later
                $this->release(60);
                return;
            }
    
            try {
                $response = $client->getConditions($this->stationId);
                $breaker->recordSuccess();
                $this->storeWeatherData($response);
            } catch (ApiException $e) {
                $breaker->recordFailure();
                throw $e; // Let Laravel's retry handle it
            }
        }
    }

    Pair It With Exponential Backoff

    Circuit breakers prevent hammering. Exponential backoff spaces out retries. Use both:

    class FetchWeatherDataJob implements ShouldQueue
    {
        public int $tries = 5;
    
        public function backoff(): array
        {
            return [30, 60, 120, 300, 600]; // 30s, 1m, 2m, 5m, 10m
        }
    }

    When You Need This

    If your application integrates with external APIs that can go down — email verification services, geocoding providers, analytics feeds — you need circuit breakers. The symptoms that tell you it’s time:

    • Thousands of identical errors in your error tracker from one endpoint
    • Queue workers stuck retrying failed jobs instead of processing good ones
    • Your application slowing down because every request waits for a timeout
    • Rate limit responses (HTTP 429) from the external service

    Without a circuit breaker, a flaky external service doesn’t just affect itself — it takes your entire queue infrastructure down with it. Five minutes of setup saves hours of firefighting.

  • Eloquent Relationship Caching: Why attach() Leaves Your Model Stale

    You call attach() on a relationship, then immediately check that relationship in the next line. It returns empty. The data is in the database, but your model doesn’t know about it.

    The Problem

    Eloquent caches loaded relationships in memory. Once you access a relationship, Laravel stores the result on the model instance. Subsequent accesses return the cached version — even if the underlying data has changed.

    // Load the relationship (caches in memory)
    $article->assignedCategory;  // null
    
    // Update the pivot table
    $newCategory->articles()->attach($article);
    
    // This still returns null! Cached.
    $article->assignedCategory;  // null (stale)
    

    The attach() call writes to the database, but the model’s in-memory relationship cache still holds the old value.

    The Fix: refresh()

    Call refresh() on the model to reload it and clear all cached relationships:

    $newCategory->articles()->attach($article);
    
    // Reload the model from the database
    $article->refresh();
    
    // Now it returns the fresh data
    $article->assignedCategory;  // Category { name: 'Technology' }
    

    refresh() re-fetches the model’s attributes and clears the relationship cache, so the next access hits the database.

    refresh() vs load()

    You might think load() would work:

    // This re-queries the relationship
    $article->load('assignedCategory');
    

    It does work for this specific relationship, but refresh() is more thorough. It reloads everything — attributes and all eager-loaded relationships. Use load() when you want to reload a specific relationship. Use refresh() when the model’s state might be stale across multiple attributes.

    When This Bites You

    This typically surfaces in multi-step workflows where the same model passes through several operations:

    // Step 1: Assign to initial category
    $defaultCategory->articles()->attach($article);
    
    // Step 2: Process the article
    $result = $pipeline->run($article);
    
    // Step 3: On failure, reassign to a different category
    if (!$result->success) {
        $defaultCategory->articles()->detach($article);
        $reviewCategory->articles()->attach($article);
    
        $article->refresh();  // Critical! Without this, downstream code sees stale category.
    
        // Step 4: Log the transition
        $transition = new CategoryReassigned($article, $reviewCategory, $defaultCategory);
        $logger->record($transition);
    }
    

    Without the refresh(), any code that checks $article->assignedCategory after step 3 will still see the old category (or null). Event handlers, logging, validation — all get stale data.

    The Pattern

    Any time you modify a model’s relationships via attach(), detach(), sync(), or toggle(), and then need to read that relationship in the same request:

    // Write
    $model->relationship()->attach($relatedId);
    
    // Refresh
    $model->refresh();
    
    // Read (now safe)
    $model->relationship;
    

    This is different from updating model attributes directly, where save() keeps the in-memory state in sync. Pivot table operations bypass the model’s state management entirely — they go straight to the database without telling the model.

    Small habit. Prevents a class of bugs that are genuinely confusing to debug because the database looks correct but the code behaves like the data doesn’t exist.