Lập trình · 22/09/2026

Laravel 13 Course – Lesson 19: Cache, Rate Limits and Atomic Locks in Laravel 13

Lesson 19 — Cache, Rate Limits and Atomic Locks: TaskFlow adds cached task counts and throttles its preview form. We also test lock ownership without treating a cache lock as a database constraint or claiming multi-machine concurrency has been verified.

Cache, Rate Limit và Atomic Lock trong Laravel 13

Lesson 19 — Cache, Rate Limits and Atomic Locks: TaskFlow adds cached task counts and throttles its preview form. We also test lock ownership without treating a cache lock as a database constraint or claiming multi-machine concurrency has been verified.

1. Three tools, three different responsibilities

Caching avoids repeated computation, rate limiting controls call frequency and locking coordinates work among callers sharing a lock store. They may use the same backend but are not interchangeable. A cache hit does not authorize access, and a request quota does not ensure only one worker runs.

TaskFlow accepts approximately 30 seconds of stale counts under ordinary operation. Do not use this number for exact write quotas, billing or access decisions. Those require appropriately protected authoritative database state.

2. Cache an integer under a project-scoped key

app/Services/ProjectTaskCount.php

<?php

namespace App\Services;

use App\Models\Project;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;

final class ProjectTaskCount
{
    // Caller authorizes the project. Counts may be stale for 30 seconds.
    public function get(Project $project): int
    {
        if (! $project->exists) {
            throw new InvalidArgumentException('A persisted project is required.');
        }

        return Cache::remember('taskflow:project:'.$project->id.':task-count:v1', 30,
            fn () => $project->tasks()->count());
    }
}

The key includes an application namespace, project ID, data purpose and schema version rather than a mutable title or arbitrary client input. Unsaved projects are rejected; callers still authorize access. Separate projects receive separate keys.

We cache an integer instead of serializing an entire model or user-specific response. Zero is cached too. TTL-based freshness is deliberate: creating a task may leave the count stale until expiry. The test demonstrates this behavior rather than pretending immediate invalidation exists.

For faster refresh, forget the precise key after commit on every relevant write path. Invalidating before commit allows readers to repopulate old data. Even after-commit invalidation needs race analysis when a reader is still computing; stronger consistency can require versioning or another data design. Do not flush the entire store to refresh one project.

3. Attach the limiter and preserve 429 responses

app/Providers/AppServiceProvider.php

<?php

namespace App\Providers;

use App\Contracts\Clock;
use App\Support\SystemClock;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        $this->app->bind(Clock::class, SystemClock::class);
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        RateLimiter::for('task-preview', fn (Request $request) => Limit::perMinute(10)
            ->by('task-preview:'.($request->user()?->id ?? $request->ip())));
    }
}

Attach the named limiter to the existing POST route:

Route::post('/tasks/preview', [TaskPreviewController::class, 'preview'])
    ->middleware('throttle:task-preview')->name('tasks.preview');

The example allows ten requests per minute per authenticated user or guest IP. Requests reaching the limiter count even when subsequent validation fails. Excess requests receive 429 and Retry-After. Ten is a teaching choice, not a universal production quota.

NAT can group multiple people behind one IP. Incorrect trusted-proxy configuration can collapse identities or permit address spoofing. Prefer stable authenticated user IDs for individual quotas when authentication is introduced. Application throttling is not complete DDoS protection.

4. Lock ownership has an expiry

$completed = Cache::lock('taskflow:project:'.$projectId.':export', 10)
    ->get(function () use ($projectId) {
        // Perform a bounded export operation here.
        return true;
    });
if ($completed === false) {
    // Another owner holds the lock; return a controlled busy response.
}

This is a scaffold, not an implemented export feature. Returning true distinguishes completion from failure to acquire the lock. The callback form releases ownership even when the callback throws. Avoid forceRelease because it can remove another owner's lock.

A ten-second lease does not stop work when it expires. Another caller can acquire the lock while an overlong original operation continues. This is not exactly-once execution. Bound runtime, evaluate renewal and protect final writes with constraints, versions or fencing where required. Do not wrap unbounded network calls in a short lease.

5. The backend defines coordination scope

TaskFlow defaults to database caching with the base cache and cache_locks migrations. Tests use an isolated array store. Multiple processes or machines must share the coordination backend and namespace. Process-local arrays and separate machine-local files do not provide cross-machine locking.

For Redis, verify the client/extension, connection, prefix and access controls. Silently falling back to an array store during an outage cannot preserve cluster-wide locks or quotas. Performance caches and correctness-critical locks may need different failure policies, explicitly designed and tested.

6. Tests and limits of the evidence

tests/Feature/TaskCacheTest.php

<?php

namespace Tests\Feature;

use App\Models\Project;
use App\Models\Task;
use App\Services\ProjectTaskCount;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class TaskCacheTest extends TestCase
{
    public function test_counts_are_project_scoped_and_refresh_after_ttl(): void
    {
        config(['cache.default' => 'array', 'database.default' => 'sqlite',
            'database.connections.sqlite.database' => ':memory:', 'database.connections.sqlite.url' => null]);
        DB::purge('sqlite');
        $this->artisan('migrate', ['--force' => true])->assertSuccessful();
        $first = Project::factory()->create();
        $second = Project::factory()->create();
        $counts = app(ProjectTaskCount::class);
        $this->assertSame(0, $counts->get($first));
        Task::factory()->for($first)->create();
        $this->assertSame(0, $counts->get($first));
        $this->assertSame(0, $counts->get($second));
        $this->travel(31)->seconds();
        $this->assertSame(1, $counts->get($first));
        $this->travelBack();
    }

    public function test_preview_rate_limit_returns_retry_after(): void
    {
        config(['cache.default' => 'array']);
        $this->withoutVite();
        for ($i = 0; $i < 10; $i++) {
            $this->post('/tasks/preview', ['title' => 'Preview', 'priority' => 'normal'])->assertOk();
        }
        $response = $this->postJson('/tasks/preview', ['title' => 'Preview', 'priority' => 'normal']);
        $response->assertStatus(429)->assertHeader('Retry-After');
        $this->assertGreaterThan(0, (int) $response->headers->get('Retry-After'));
        $this->travel(61)->seconds();
        $this->post('/tasks/preview', ['title' => 'Preview', 'priority' => 'normal'])->assertOk();
        $this->travelBack();
    }

    public function test_lock_rejects_second_owner_and_releases_after_exception(): void
    {
        $store = Cache::store('array');
        $first = $store->lock('taskflow:test:export:1', 10);
        $second = $store->lock('taskflow:test:export:1', 10);
        $this->assertTrue($first->get());
        $this->assertFalse($second->get());
        $first->release();
        $this->assertTrue($second->get());
        $second->release();
        try {
            $store->lock('taskflow:test:export:1', 10)->get(function () {
                throw new \RuntimeException('Export failed');
            });
        } catch (\RuntimeException $exception) {
            $this->assertSame('Export failed', $exception->getMessage());
        }
        $last = $store->lock('taskflow:test:export:1', 10);
        $this->assertTrue($last->get());
        $last->release();
    }
}
php artisan test --filter=TaskCacheTest
php artisan test

Tests cover project isolation, stale counts before TTL and fresh counts afterward, HTTP 429 and recovery, rejection of a second lock owner, and release after exceptions. The suite passed 51 tests with 182 assertions. These are sequential array-store tests, not Redis failover or multi-process concurrency tests. Add backend-specific integration tests before claiming those properties.

Exercises: count queries on cache hits, test independent IP quotas, and simulate an expired lease while its original owner still works. Explain the resulting overlap. References: Laravel Cache and Rate Limiting.

Navigation: Lesson 18 · Roadmap. Next: authentication and starter kits.

Laravel 13 course navigation

Previous lesson (18) · Next lesson (20) · All 37 lessons

Discussion

Comments 0

Sign in to comment

You need an account to join the discussion and reply to other readers.

Sign inRegister

No comments yet. Be the first to share your thoughts.