From API to Test Suite
From API to Test Suite: everything we built today
A working REST API, real authorization, background jobs, caching, and a test suite that proves it all still works tomorrow. Here's the whole path, in order, with the reasoning behind each turn.
Building the RESTful API
We started with routes, a controller, and a resource — the three pieces every Laravel API needs before it can say anything meaningful back to a client.
Versioned routes keep old clients from breaking when the API changes shape later:
Route::prefix('v1')->group(function () {
Route::apiResource('posts', PostController::class);
});
An API-only resource controller skips the view-related actions (create, edit) that make no sense for JSON:
php artisan make:controller Api/V1/PostController --api --model=Post
And a PostResource controls exactly what shape of JSON goes out — never raw Eloquent models:
'author' => $this->whenLoaded('user', fn () => $this->user->name),
'category' => $this->whenLoaded('category', fn () => $this->category->name),
GET /api/v1/posts returns clean, versioned JSON, with the author and category names inline instead of nested foreign keys.The data model behind it
The resource above only works if Post actually has something to eager-load. Three models, wired together:
Each post carries title, body, price, is_published, plus a user_id and category_id. The migration:
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
$table->decimal('price', 8, 2)->default(0);
$table->boolean('is_published')->default(false);
One easy mistake to repeat: price is stored as decimal(8,2). Passing 1999 stores $1,999.00, not $19.99 — the intended cents value needs to be written explicitly.
role on users came later, added as its own migration rather than reworking the original users table:
$table->string('role')->default('author')->after('password');
Post::with('user','category')->get() returns real, related data for every seeded row.The slug bug, and why uniqueness matters
Categories auto-generate a slug on creation:
static::creating(function (Category $category) {
if (empty($category->slug)) {
$category->slug = Str::slug($category->name);
}
});
But the migration declares slug as unique(). Create two categories named "Laravel", and the second insert throws a raw QueryException instead of a clean validation error — an ugly 500, not a 422.
laravel, laravel-1, laravel-2) before saving, rather than trusting the generated slug is unique by luck.Caching with Redis
Categories rarely change — no reason to query the database on every request for them.
Cache-aside: read from cache, fall back to the database only on a miss.
$categories = Cache::remember('categories.all', now()->addHours(6), function () {
return Category::all();
});
Invalidate on change, via an observer — otherwise the cache goes stale the moment a category is edited:
// app/Observers/CategoryObserver.php
public function saved(Category $category): void
{
Cache::forget('categories.all');
}
For things that change constantly — like view counts — Redis is used directly, not through the cache facade:
Redis::incr('post:' . $post->id . ':views');
GET /api/v1/categories never touch the database; editing a category clears the cache immediately.When Redis isn't there: the PHPSandbox fix
Sandboxed environments like PHPSandbox don't ship a running Redis server — REDIS_HOST=127.0.0.1 points at nothing, and every call fails with connection refused.
For a teaching environment, drop back to drivers that need no external service:
CACHE_STORE=file
And replace the Redis-specific counter with Cache::increment(), which works on any driver:
public function show(Post $post)
{
Cache::increment('post:' . $post->id . ':views');
return new PostResource($post->load('user', 'category'));
}
In production, this would point at real Redis for atomic increments at scale — the sandbox demonstrates the pattern, not the exact infrastructure.
Service + Repository: why we split the logic
Once the controller started doing more than plumbing, the logic moved out — first into a PostService, then behind a PostRepositoryInterface so it could be tested without a database.
| Layer | Job |
|---|---|
PostController | HTTP concerns only — status codes, request/response shape |
PostService | Business rules — e.g. new posts default to unpublished |
PostRepositoryInterface | The contract — lets tests swap in a mock |
PostRepository | The real implementation — talks to Eloquent |
Bound once, in a provider, so the whole app resolves the interface to the real class automatically:
// app/Providers/AppServiceProvider.php
$this->app->bind(PostRepositoryInterface::class, PostRepository::class);
Proven directly in Tinker, no HTTP involved:
$service = app(PostService::class);
$post = $service->createPost([...], userId: $user->id);
$post->is_published; // false — confirms the default
Testing it all, permanently
A Tinker check proves something works once, right now, on your machine. A test proves it keeps working — automatically, forever, for anyone.
--unit | feature (default) | |
|---|---|---|
| Folder | tests/Unit/ | tests/Feature/ |
| Boots Laravel? | No | Yes — full app, DB, routing |
| Good for | One class's logic, mocked dependencies | Real HTTP request → response, auth, DB writes |
The unit test proves PostService defaults new posts to unpublished — with the repository mocked out entirely:
$repo->shouldReceive('create')
->once()
->with(Mockery::on(fn ($data) => $data['is_published'] === false));
The feature test proves the same rule holds through the real HTTP layer, database included:
$this->actingAs($user, 'sanctum')->postJson('/api/v1/posts', [...])
->assertStatus(201);
Both run against an isolated in-memory database — never the real dev DB:
<!-- phpunit.xml -->
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
php artisan test passes end to end, in under a second, touching nothing but the throwaway in-memory database.The full chain, start to finish
/api/v1/posts)→ Controller (HTTP shape)
→ Service (business rules)
→ Repository Interface (the contract)
→ Repository (talks to Eloquent)
→ Model (
Post, Category, User)→ Resource (JSON shape out)
⤷ backed by cache (Redis/file) and covered by tests (Unit + Feature)
Every piece added today exists to answer one question cleanly: what happens when this breaks, and how would I know? Routes and resources answer it for shape. Policies answer it for access. Queues and events answer it for side effects. Caching answers it for speed. And the test suite answers it for time — so the answer stays true after today.