API versioning strategies in Laravel
Concept
Code organization conventions make Laravel projects maintainable as they grow. Following a consistent structure prevents controllers from becoming 1,000-line classes and business logic from leaking everywhere.
Single Action Controllers: For complex endpoints, use a single-action controller (__invoke()) instead of a resource controller. php artisan make:controller StoreOrderController --invokable. Each controller file does exactly one thing — easy to find, easy to test.
Action classes: A single public execute() or handle() method. Dependency injection in constructor. Not coupled to HTTP. Can be called from controllers AND Artisan commands AND jobs. Similar to service classes but even more focused.
Directory structure beyond app/:
app/Actions/— single-action classes.app/Services/— multi-method domain services.app/DataTransferObjects/— DTOs.app/Queries/orapp/Repositories/— data access layer.app/Exceptions/— domain exceptions.app/Support/— helpers, macros, value objects.
Resource controllers: Use Route::apiResource() for standard CRUD. Controller methods: index, store, show, update, destroy. Stick to these — if you need more, it's a sign the controller has too many responsibilities.
app/Http should only contain HTTP concerns: Middleware, Requests, Resources, Controllers. No business logic. Controllers are thin coordinators.
Naming conventions:
- Controllers:
PostController,UserController(noun + Controller). - Actions:
CreatePost,PublishPost,DeleteExpiredPosts(verb + noun). - Services:
PostService,BillingService(noun + Service). - Events:
PostPublished,UserRegistered(noun + past tense). - Listeners:
SendPostPublishedNotification,UpdateUserLastSeen(verb phrase). - Jobs:
SendWeeklyReport,ProcessPayment(verb + noun).
Code Example
<?php
// Single action controller
namespace App\Http\Controllers;
use App\Actions\CreatePost;
use App\Http\Requests\CreatePostRequest;
use Illuminate\Http\JsonResponse;
class StorePostController extends Controller
{
public function __invoke(CreatePostRequest $request, CreatePost $action): JsonResponse
{
$post = $action->execute($request->user(), $request->validated());
return response()->json(new \App\Http\Resources\PostResource($post), 201);
}
}
// Action class — reusable, HTTP-independent
namespace App\Actions;
use App\Models\Post;
use App\Models\User;
use App\Events\PostCreated;
use Illuminate\Support\Str;
class CreatePost
{
public function execute(User $author, array $data): Post
{
$post = Post::create([
'user_id' => $author->id,
'title' => $data['title'],
'slug' => Str::slug($data['title']),
'body' => $data['body'],
'status' => $data['status'] ?? 'draft',
]);
if (isset($data['tags'])) {
$post->tags()->sync($data['tags']);
}
PostCreated::dispatch($post);
return $post;
}
}
// Route registration — clean and explicit
Route::post('/posts', \App\Http\Controllers\StorePostController::class);
Route::get('/posts', [\App\Http\Controllers\PostController::class, 'index']);
// Invokable from command too — no HTTP needed
class CreateDemoContentCommand extends \Illuminate\Console\Command
{
protected $signature = 'demo:seed';
public function handle(\App\Actions\CreatePost $action): int
{
$user = User::factory()->create();
$post = $action->execute($user, ['title' => 'Demo Post', 'body' => '...']);
$this->info("Created post: {$post->id}");
return self::SUCCESS;
}
}