Skip to content

ADR-0011: Action Class Architecture

Accepted Cross-Project

Date: 2026-03-08

Context

Laravel controllers tend to accumulate business logic over time — validation, authorization, database mutations, notifications, and side effects all end up in controller methods. This makes controllers hard to test in isolation, hard to reuse from non-HTTP contexts (queued jobs, artisan commands, MCP tools), and hard to reason about.

The project needs a pattern that:

  • Separates business logic from HTTP concerns
  • Is independently unit-testable with mocked dependencies
  • Works from any entry point (controller, job, command, MCP tool)
  • Enforces explicit dependency injection (no global state, no facades)

The Problem for AI Agents

Standard Laravel tutorials put logic in controllers and use facades (DB::transaction(), Auth::user()). Without documentation, AI agents will generate controller-heavy code with facades and implicit global state — violating every convention in these codebases.

Decision

All business logic lives in Action classesfinal readonly classes with a single public execute() method. Controllers are thin: validate → delegate → respond.

Class Structure

php
declare(strict_types=1);

final readonly class CreateIssueAction
{
    public function __construct(
        private ConnectionInterface $db,
        private Issue $issue,
        private IssueAuditLogger $auditLogger,
        private NotifyMentionedUsersAction $notifyMentions,
    ) {}

    public function execute(Project $project, User $user, SaveIssueData $data, RequestContext $requestContext): Issue
    {
        $issue = $this->db->transaction(function () use ($project, $user, $data, $requestContext): Issue {
            $issue = $this->issue->newInstance();
            $issue->title = $data->title;
            $issue->description = $data->description;
            $issue->lane_id = $data->laneId;
            $issue->project_id = $project->id;
            $issue->save();

            $this->auditLogger->logCreated($issue, $user, $requestContext);

            return $issue;
        });

        // Side effects (notifications) outside transaction — can safely fail
        $this->notifyMentions->execute($issue, $user);

        return $issue;
    }
}

Mandatory Rules

All rules are enforced by architecture tests (e.g., tests/Arch/ActionsTest.php).

Class Rules

RuleEnforcement
final readonly classtoBeFinal()->toBeReadonly()
Suffix ActiontoHaveSuffix('Action')
declare(strict_types=1)toUseStrictTypes()
No base classtoExtendNothing()
No interfacestoImplementNothing()
Exactly one public method: execute()Reflection test on public methods

Dependency Rules

RuleWhy
No facades (Illuminate\Support\Facades)Explicit injection, no hidden global state
No DatabaseManager — use ConnectionInterfaceInterface over implementation
No HTTP layer classes (Request, UploadedFile)Actions are HTTP-agnostic
No RuntimeExceptionUse domain-specific exceptions

Transaction Rules

RuleWhy
If Action mutates DB → must inject ConnectionInterfaceEnsures transactions are possible
If Action has ConnectionInterface → must call $this->db->transaction()No unused dependencies
All Eloquent mutations (save(), delete(), update(), sync()) inside transaction closuresAtomic operations
Transaction closures must have explicit return type (: void, : Issue, etc.)Prevents silent return value loss
No arrow functions (fn()) in transactionsArrow functions silently discard return values

Model Usage Rules

PatternCorrectWrong
Create new model$this->issue->newInstance()new Issue(), Issue::create()
Query models$this->issue->newQuery()->where(...)Issue::where(...), $this->issue::where(...)
Static through instanceNot allowed$this->model::where()

Controller Integration

Controllers inject the Action as a method parameter. Laravel's service container auto-resolves all constructor dependencies:

php
public function store(
    SaveIssueRequest $request,
    Project $project,
    #[CurrentUser] User $user,
    CreateIssueAction $action,
): JsonResponse {
    $issue = $action->execute($project, $user, $request->toDto(), RequestContext::fromRequest($request));
    return new IssueResource($issue->load(IssueResource::EAGER_LOAD))->response()->setStatusCode(201);
}

Action Composition

Actions can inject and call other Actions. This replaces service classes and event-driven patterns:

php
final readonly class CreateIssueAction
{
    public function __construct(
        private ValidateBlockingRelationshipsAction $validateBlockingRelationships,
        private NotifyMentionedUsersAction $notifyMentions,
        private NotifyAssignmentChangeAction $notifyAssignment,
        private DetectBotAssignmentAction $detectBotAssignment,
        // ...
    ) {}
}

Return Patterns

PatternWhen
return ModelCreate operations — return the created entity
voidUpdate/delete — model modified in-place or removed
?ModelOptional lookup — returns null if not found
Throws exceptionValidation guards — InvalidArgumentException on failure

Audit Logging Integration

Actions that mutate auditable models must inject the corresponding audit logger and call it inside the transaction. For updates, snapshot the old state before the transaction:

php
$oldValues = $this->auditLogger->snapshotIssue($issue);

$this->db->transaction(function () use ($issue, $user, $requestContext, $oldValues): void {
    $issue->title = $data->title;
    $issue->save();
    $this->auditLogger->logUpdated($issue, $user, $requestContext, $oldValues);
});

See Audit Logging for the full audit system design.

RequestContext

Every audited Action receives a RequestContext DTO capturing the origin of the request:

SourceConstruction
HTTP requestRequestContext::fromRequest($request) — captures IP, user agent, URL
MCP toolnew RequestContext(ipAddress: null, userAgent: 'MCP/' . class_basename(self::class), url: null)
System/CLIRequestContext::system() — all fields null

Config Injection

Actions that need configuration values use the #[Config] attribute:

php
public function __construct(
    #[Config('claude.default_templates')]
    private array $defaultTemplates,
) {}

Options Considered

OptionVerdictReason
Fat controllersRejectedNot reusable from jobs/commands/MCP, hard to unit test, accumulates complexity
Service classes (multiple public methods)RejectedGrow into god objects. Actions enforce single-responsibility via the one-public-method rule.
Model-level logic (observers/mutators)RejectedImplicit execution, hard to test, query-level operations bypass model events silently
Action classes (final readonly, one execute())AcceptedExplicit DI, no global state, composable, testable, 15+ architecture tests enforce the pattern

Consequences

Positive

  • Business logic is independently testable — mock dependencies, call execute(), assert results
  • Actions work from any entry point — controllers, jobs, commands, MCP tools all call the same Action
  • final readonly prevents inheritance hierarchies and mutable state
  • Architecture tests catch violations at CI — AI-generated code that uses facades or skips transactions fails immediately
  • Composition over inheritance — Actions call other Actions, building complex flows from simple units

Negative

  • More files per feature: Controller + FormRequest + DTO + Action + Test (5 files minimum)
  • Verbose: explicit dependency injection means long constructor parameter lists
  • AI agents unfamiliar with this pattern will generate facade-based code that fails architecture tests
  • New developers must learn the Action pattern before contributing

References

Architecture documentation for contributors and collaborators.