<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ArticleCollection extends ResourceCollection
{
public $collects = ArticleResource::class;
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
];
}
public function with(Request $request): array
{
return [
'meta' => [
'version' => 'v1',
'total' => $this->resource->total(),
],
'links' => [
'self' => $request->url(),
],
];
}
}
<?php
use App\Http\Controllers\Api\V1\ArticleController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')
->name('api.v1.')
->middleware(['throttle:api'])
->group(function () {
Route::get('articles', [ArticleController::class, 'index'])->name('articles.index');
Route::get('articles/{article}', [ArticleController::class, 'show'])->name('articles.show');
Route::middleware('auth:sanctum')->group(function () {
Route::post('articles', [ArticleController::class, 'store'])->name('articles.store');
});
});
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\V1\ArticleCollection;
use App\Http\Resources\V1\ArticleResource;
use App\Models\Article;
use Illuminate\Http\Request;
class ArticleController extends Controller
{
public function index(Request $request)
{
$articles = Article::query()
->with(['author', 'tags'])
->latest('published_at')
->paginate($request->integer('per_page', 15));
return new ArticleCollection($articles);
}
public function show(Article $article)
{
$article->load(['author', 'tags']);
return new ArticleResource($article);
}
public function store(Request $request)
{
$data = $request->validate([
'title' => ['required', 'string', 'max:180'],
'body' => ['required', 'string'],
]);
$article = $request->user()->articles()->create($data);
return (new ArticleResource($article))
->response()
->setStatusCode(201);
}
}
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ArticleResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'authorName' => $this->whenLoaded('author', fn () => $this->author->name),
'tags' => $this->whenLoaded('tags', fn () => $this->tags->pluck('name')),
'publishedAt' => optional($this->published_at)->toIso8601String(),
];
}
public function with(Request $request): array
{
return [
'meta' => ['version' => 'v1'],
];
}
}
This snippet shows how to version a Laravel JSON API cleanly by combining a versioned route group with dedicated API Resource classes per version, so the wire format stays stable even as the underlying Article model evolves. The core idea is separation: the route group decides which version is being served, and the resource decides how the model is serialized for that version. Keeping both concerns explicit avoids the common trap of leaking new database columns into old clients.
In routes/api.php, all endpoints are nested under a prefix('v1') group with a name('api.v1.') prefix so route names never collide across versions. The group also attaches middleware like throttle:api and auth:sanctum in one place, which means a future v2 group can reuse the same controller or point to a V2 namespace without duplicating cross-cutting config. Grouping by prefix is preferred over header-based versioning here because it is trivially cacheable, easy to document, and visible in logs.
The ArticleController stays thin and version-agnostic in structure: index wraps a paginated query in an ArticleCollection, and show wraps a single model in an ArticleResource. Returning a resource instead of response()->json($model) is the key move — the controller never decides field names, so business logic and presentation don't blur together.
ArticleResource defines the exact v1 contract in its toArray method. Note how authorName is derived from a relationship and tags is coerced with whenLoaded to avoid N+1 surprises and to omit data the caller didn't request. The with method appends a stable meta.version envelope to every response, which is invaluable for client-side debugging. Casting published_at explicitly guards against timezone drift in the serialized output.
ArticleCollection sets $collects to bind each element to ArticleResource, then augments the top-level payload with meta.total and a links.self pointer. A subtle pitfall this addresses: when a collection wraps resources, per-item with data is ignored, so shared metadata belongs on the collection. When a v2 contract is needed, a developer copies these resource classes rather than editing them, guaranteeing old clients keep their guarantees.
Related snips
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
Django URL namespacing and reverse lookups
Share this code
Here's the card — post it anywhere.