Design Patterns And Data Architecture

Action Pattern

An action is a focused class that performs one application operation. In PHP projects, actions are common when controllers start doing too much but a full command bus would be unnecessary. An action gives one use case a name, a constructor for its dependencies, one obvious entry method, and a place for tests that do not need the whole HTTP layer.

The action pattern overlaps with application services. Some teams would call PublishArticle an action, while others would call it an application service. That naming difference is less important than responsibility. The class should own one operation, not a whole area of the product. PublishArticle, CancelSubscription, ImportCsvRow, and SendPasswordResetLink are action-shaped names. ArticleManager and UserService are broader names and need more scrutiny.

Actions are especially useful in Laravel-style codebases where controller methods can delegate quickly, in small custom MVC applications, in CLI commands that share behavior with HTTP endpoints, and in queue jobs that should call the same operation as a browser request. The action is the reusable application boundary; the controller, command, or job is only the delivery mechanism.

What An Action Owns

An action owns the orchestration for one use case. It may validate application-level preconditions, load data through repositories, call domain behavior, save changes, record events, call infrastructure services, and return a clear result. It should not parse raw HTTP requests, render templates, or know framework-specific redirect behavior unless the action is explicitly a web action in that framework's style.

A good action answers a sentence such as "cancel this subscription", "publish this article", or "send a password reset link". If the sentence needs "and" several times, the class may be too broad.

The action should receive dependencies through its constructor. It should receive use-case input through its entry method or a small input object. That keeps dependencies stable and request-specific data explicit.

A Small Action Example

This example publishes an article. The class has one clear job and one entry method.

PHP example
<?php

declare(strict_types=1);

final class Article
{
    public function __construct(
        public string $title,
        public bool $published = false,
    ) {
    }

    public function publish(): void
    {
        if ($this->published) {
            throw new RuntimeException('Article is already published.');
        }

        $this->published = true;
    }
}

final class PublishArticle
{
    public function execute(Article $article): string
    {
        $article->publish();

        return 'Published: ' . $article->title;
    }
}

$article = new Article('Design boundaries');
$action = new PublishArticle();

echo $action->execute($article) . PHP_EOL;

// Prints:
// Published: Design boundaries

A real action might load the article from a repository, check permissions, save changes, and record an event. The example keeps the boundary small: the action coordinates the use case while the entity owns the state transition rule.

Entry Method Names

Common action entry names include execute(), handle(), and __invoke(). Pick one convention per codebase. A mixture of all three makes actions harder to scan unless the framework has a reason.

__invoke() is convenient when the action can be called like a function or when a framework resolves invokable controllers. handle() is common around jobs and command handlers. execute() is explicit and easy to search. The method name is less important than consistent input and output.

Do not hide several operations behind one generic execute(string $mode, array $data) method. If the mode changes the use case, split the action.

Authorization And Idempotency

Actions often sit exactly where authorization and idempotency need a clear answer. A controller can check coarse route access, but the action usually has the loaded resource and the intended state transition. That makes it a good place to call a policy or guard that answers whether the actor may perform this specific operation. Keep the policy explicit; do not bury permission checks inside a repository query unless the method name and tests make that behavior obvious.

For actions triggered by retries, queues, or external callbacks, define idempotency. CancelSubscription might safely return an already-cancelled result on a repeated call, while CapturePayment may require an idempotency key and provider reference. The action should make duplicate handling visible in its result or documented exception path so callers do not guess whether retrying is safe.

Action Versus Controller

A controller is an HTTP adapter. It receives a request, reads route and body data, handles framework validation or delegates it, calls the action, and converts the result into an HTTP response. It owns redirects, status codes, flash messages, JSON response shape, and template selection.

An action owns the application operation. It should be callable from a controller, CLI command, queue job, scheduler, or test without pretending an HTTP request exists.

If an action receives Request $request and returns RedirectResponse, it may simply be a controller under another name. That is sometimes a framework convention, but it is not a reusable application action. For reusable actions, pass explicit values or input objects and return application results.

Action Versus Service

An action is usually narrower than a service. RegisterUser as an action and RegisterUser as an application service can be the same design with different vocabulary. The problem appears when a service has many unrelated methods and dependencies. Actions avoid that by defaulting to one operation per class.

Use action classes when use cases are numerous and controllers need a consistent delegation style. Use broader service classes when a small cohesive capability genuinely has several related operations, such as PasswordHasher::hash() and PasswordHasher::verify().

Do not create both an action and a service for the same thin operation unless they have distinct responsibilities. CancelSubscriptionAction calling SubscriptionService::cancelSubscription() often adds a layer without adding clarity.

Inputs And Results

Action inputs should be explicit. For very small operations, method arguments can be enough. For larger operations, use an input DTO such as RegisterUserInput or CancelSubscriptionInput. The action should not accept raw unvalidated arrays unless its job is to validate them.

Results should also be explicit. An action can return a domain object, a result object, an enum-like outcome, or void when success is enough. Throwing exceptions for exceptional failures is fine, but routine business outcomes such as already cancelled, not allowed, or duplicate email may be better represented as results if callers need to handle them normally.

Avoid returning mixed arrays that every controller interprets differently. That recreates the vague boundary the action was meant to remove.

Transactions And Side Effects

Actions often own transaction coordination. If the use case changes database state and records an event, the action should make the order clear. It may call a transaction manager, unit of work, entity manager, or repository method that participates in a larger transaction.

External side effects need special care. Sending email, charging a card, publishing to a queue, or calling a webhook does not roll back with the database. Actions should use outbox records, idempotency keys, jobs, or explicit recovery rules when a side effect and database write must stay consistent.

A good review question is: what remains true if the process crashes after each step? If nobody can answer, the action boundary is not yet clear enough.

Testing Actions

Actions are excellent test targets. They sit above entities and repositories but below controllers. Tests can use fake repositories, fake clocks, fake notifiers, fake event recorders, and in-memory objects.

Test success and meaningful failure paths. For CancelSubscription, test missing subscription, already cancelled subscription, unauthorized actor, successful cancellation, persistence failure, and event recording behavior. Do not test only the happy path.

Keep HTTP assertions in controller tests. An action test should not assert redirect URLs or rendered HTML. It should assert application behavior and returned result.

Common Failure Modes

One failure mode is the pass-through action. A class with execute() that only calls one repository method with the same arguments may not be useful. It needs a use-case name, rule, transaction, side effect, or testing boundary to justify itself.

Another failure mode is the fat action. An action can become a long procedural script that validates raw request data, runs SQL, mutates domain state, sends external calls, builds view arrays, and catches every exception. Split responsibilities when that happens.

A third failure mode is inconsistent action conventions. Some actions return arrays, some return redirects, some throw for business outcomes, and some mutate public properties. Agree on conventions so controllers and tests remain predictable.

A fourth failure mode is using actions to avoid domain modeling. If every rule lives in actions and entities only hold data, duplication can appear across actions. Move stable domain rules to entities, value objects, policies, or domain services.

Review Criteria

When reviewing an action, ask whether its name describes one use case. Check that constructor dependencies are relevant to that use case. Check that input is explicit and output is understandable. Check that HTTP, template, and provider details stay outside unless the action deliberately owns that adapter boundary.

Check transaction and side-effect ordering. Check tests for success, rejection, duplicate or conflict, dependency failure, and side-effect behavior. Check whether the action can be called from another delivery mechanism without faking a web request.

What To Check

Before moving on, make sure you can:

  • explain an action as a one-use-case application class
  • distinguish action, controller, service, and command handler vocabulary
  • choose consistent entry method naming
  • pass explicit inputs instead of raw framework requests
  • return clear application results
  • coordinate transactions and side effects deliberately
  • test action behavior below the HTTP layer

After this lesson, you should be able to identify a controller method that is doing too much, extract one use case into an action, and keep the action focused enough that another developer can test and reuse it safely.

Practice

Task: Extract An Action

A controller method validates a route ID, loads an article, publishes it, saves it, records an audit event, and redirects back to the article page.

Design an action extraction.

Requirements

Name the action, its dependencies, input, result, and what remains in the controller.

Check your work

The action should own the publishing use case, not the redirect response.

Show solution

The action loads the article, applies the publish rule, saves it, records the audit event, and returns a result containing the article ID and outcome. Missing article, already published, and permission denial should be distinct outcomes or typed exceptions according to project convention.

The controller keeps HTTP work: reading the route ID, obtaining the current actor, calling the action, translating the result into a redirect, status code, flash message, or error response.

Task: Review An Action Boundary

A RegisterUserAction accepts the full HTTP request, reads form fields, creates a user, sends email, sets flash messages, and returns a redirect response.

Review the boundary and propose a cleaner version.

Requirements

Mention request parsing, input DTOs or explicit arguments, result objects, email side effects, and controller responsibilities.

Check your work

The answer should make the action reusable outside HTTP.

Show solution

The action is coupled to HTTP because it accepts the full request, sets flash messages, and returns a redirect. That makes it hard to reuse from CLI, tests, jobs, or another endpoint.

Move request parsing and flash/redirect behavior back to the controller. Pass explicit values or a RegisterUserInput DTO to RegisterUser. Return a RegisterUserResult with outcomes such as registered, duplicate email, invalid input, or temporary failure.

Email should be coordinated through a notifier, job, or outbox depending on reliability needs. The action can record that verification should be sent, while the controller decides how to present the result to the browser.

Task: Test An Action

A CancelSubscription action loads a subscription, checks whether the actor owns it, cancels it, saves it, and records an event.

Plan focused tests.

Requirements

Include success, missing subscription, wrong owner, already cancelled, repository save failure, and event recording.

Check your work

The tests should not require rendering an HTML page.

Show solution

Use fake repositories and a fake event recorder. The success test should load an active subscription owned by the actor, call the action, assert the subscription is cancelled, assert it is saved, and assert one cancellation event is recorded.

The missing subscription test should return not found or throw the project's not-found exception and should not save or record an event. The wrong-owner test should return forbidden and should not mutate state. The already-cancelled test should return a conflict or idempotent success according to product rules.

The save-failure test should prove the action does not report success. If event recording happens through an outbox in the same transaction, verify the event is not committed when save fails. No HTML rendering or redirect assertion belongs in these action tests.