The Controller That Knew Too Much
We've all seen it. A controller method starts at 15 lines. Six months later, it's 140 lines of nested transactions, Stripe webhooks, notification dispatching, and conditional log logic. The quick fix is often extracting code into an Action class or wrapping input in a Data Transfer Object (DTO). But if you blindly turn every single database call into a standalone class, you end up with 200 files for a basic CRUD app. That's not architecture; that's ritual.
PHP 8.3 and Laravel 12 give us native language features that make DTOs and action classes lightweight. You don't need third-party packages to get clean boundaries. You just need to know where to draw the line between useful isolation and useless boilerplate.
Building a DTO with PHP 8.3 Readonly Classes
Passing raw arrays between HTTP requests, queue jobs, and domain logic is an invitation for bugs. You forget whether a key was named user_id or userId, or whether email_verified_at is a string or a Carbon instance. Array keys fail quietly at runtime until production throws an Undefined array key error.
In PHP 8.3, a DTO is just a readonly class with promoted properties. Here's a clean implementation for onboarding a new team member with specific role permissions and notification settings:
namespace App\DataObjects;
use App\Http\Requests\OnboardUserRequest;
use Carbon\CarbonImmutable;
readonly class OnboardUserData
{
public function __construct(
public string $name,
public string $email,
public string $role,
public bool $sendInvite,
public ?CarbonImmutable $startsAt = null,
) {}
public static function fromRequest(OnboardUserRequest $request): self
{
return new self(
name: $request->string('name')->trim()->toString(),
email: $request->string('email')->lower()->trim()->toString(),
role: $request->string('role', 'member')->toString(),
sendInvite: $request->boolean('send_invite', true),
startsAt: $request->filled('starts_at')
? CarbonImmutable::parse($request->string('starts_at'))
: null,
);
}
}Notice what this solves. The DTO guarantees that whenever your application code works with user onboarding data, every property has a guaranteed type. There is no guessing. If a queue job receives this object, static analysis tools like PHPStan at level 8 or 9 immediately know what types are present.
Designing Single-Purpose Action Classes
An action class handles one specific business operation. It doesn't care if it's called from an HTTP controller, a Scheduled Console Command, an Event Listener, or a Pest test. It accepts input—ideally as a DTO—executes domain logic, and returns a result.
Here is an action class handling complex onboarding steps: creating the database record, creating an initial team workspace, and queueing an invitation mail.
namespace App\Actions;
use App\DataObjects\OnboardUserData;
use App\Models\User;
use App\Models\Team;
use App\Mail\UserInviteMail;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
class OnboardUserAction
{
public function execute(OnboardUserData $data): User
{
return DB::transaction(function () use ($data) {
$user = User::create([
'name' => $data->name,
'email' => $data->email,
'role' => $data->role,
'starts_at' => $data->startsAt,
]);
$team = Team::create([
'name' => $user->name . "'s Workspace",
'owner_id' => $user->id,
]);
$user->teams()->attach($team, ['role' => 'owner']);
if ($data->sendInvite) {
Mail::to($user->email)->queue(new UserInviteMail($user));
}
return $user;
});
}
}Where Action Classes Win
Action classes shine when business logic must run from multiple entry points. Consider an application where a user can be onboarded via an administrative API, a web sign-up form, or an automated CSV import Artisan command. Without an action class, you repeat the team creation and database transaction logic in three places or hide it inside an Eloquent model event that triggers unexpectedly during testing.
Testing gets significantly easier too. Instead of spinning up full HTTP requests with $this->postJson() just to test your business rules, you unit test the action directly. Running 100 HTTP integration tests in Laravel 12 might take 3.2 seconds; running 100 direct action tests takes around 350ms because you skip route registration, middleware execution, and request synthesis.
When Action Classes Become Bureaucracy
The biggest trap developers fall into is treating every database mutation as a candidate for an action class. If your controller method looks like this:
public function destroy(Post $post): RedirectResponse
{
$post->delete();
return redirect()->route('posts.index');
}Do not create a DeletePostAction. Wrapping $post->delete() in a separate class with an __invoke method adds zero safety, zero reusability, and three additional files to navigate in your editor. You've added indirection without adding abstraction.
Here is a list of red flags indicating over-engineering:
- Pass-through Actions: An action class that simply calls an Eloquent method like
User::create($data)without any surrounding logic, events, or transactions. - Single-Property DTOs: Creating a DTO for a request that carries only a single field, like updating an email address. A simple string parameter is sufficient.
- Anemic Controller Syndrome: Controllers that do nothing except instantiate a DTO and pass it to an action, even for basic 4-line CRUD operations.
- Action Cascades: An action that calls three other actions, which each call two actions. High call-stack depth makes debugging database transactions excruciating.
The Hybrid Rule: When to Use What
To keep codebases clean without losing speed, follow a simple decision workflow before creating new classes:
- Standard Eloquent CRUD: Keep it in the Controller or Form Request. If a controller updates a single model column or handles standard RESTful operations without side effects, let Eloquent handle it right there.
- Multiple Entry Points: If the same action needs to happen from an HTTP request, a queued job, or a CLI command, extract an Action class immediately.
- Complex Side Effects: If creating a record triggers secondary model creation, external API integration, custom cache invalidation, or conditional emails, use an Action class. Wrapping these steps inside a single database transaction in an Action prevents state corruption.
- High Property Count & Mixed Types: If an endpoint takes more than 4 or 5 parameters, or if keys require type conversions like dates or nested arrays, build a DTO.
Integrating Action Classes and DTOs in Controllers
When you do need both, your controller remains uncluttered, acting purely as an HTTP translator. It validates input, constructs the DTO, delegates to the action, and returns an HTTP response.
namespace App\Http\Controllers;
use App\Actions\OnboardUserAction;
use App\DataObjects\OnboardUserData;
use App\Http\Requests\OnboardUserRequest;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class OnboardUserController extends Controller
{
public function __invoke(
OnboardUserRequest $request,
OnboardUserAction $action,
): JsonResponse {
$data = OnboardUserData::fromRequest($request);
$user = $action->execute($data);
return response()->json([
'message' => 'User onboarded successfully',
'user_id' => $user->id,
], Response::HTTP_CREATED);
}
}Notice how easy this controller is to read. It doesn't know how workspaces are assigned or how emails are queued. It handles HTTP input, calls the application boundary, and formats the output. If the business requirement changes tomorrow to require assigning a default storage quota to new users, you modify OnboardUserAction without touching a single HTTP file or test response assertion.
Practical Guidelines for Laravel Architecture
Structure should serve your development velocity, not slow it down. PHP 8.3 readonly classes give us strict data structures without overhead. Laravel 12 provides powerful request handling out of the box. Combine them where complex rules demand isolation, but stay pragmatic when standard model methods are enough.












