Vasiliy Krivoplyas
← Home

Architecture · PHP · Yii2 / Laravel

PHP application architecture: add complexity for a reason

It grew out of pain on real projects. As many layers as the task needs — no more, and a short path to release. Add structure only when you can name why.

Add complexity when

I start with the minimum: input, what to do with it, result. A new class appears when I can point at the pain: checks have drifted apart, the same input is assembled in three places, create and rename share one Service so changing one drags in the other’s dependencies, the JSON mirrors table columns and a new DB field shows up in the API.

Three roles

A controller, console command or Job accepts input and returns a result. The boundary checks format. “May we?”, the order of writes, and the transaction live in a Service. The subject is a method argument, not the “current user” inside the service. Data access is the ORM or a Repository.

A uniqueness check before writing does not replace UNIQUE in the database. If the operation depends on mutable state — remaining stock, a slot, a limit — use an UPDATE with a condition such as qty >= 1 and check that a row changed, or a lock. One transaction does not close every race.

If the scenario is short and only the API calls it, it can stay in the controller action. Move it to a Service when console or a queue needs the same code, or when the action has grown fat: field checks, “may we?”, three tables, mail.

  1. HTTP CLI Job
    Entry pointAccept input, check fields, return a result
  2. OperationThe same scenario from HTTP, CLI and Job. Who it is comes as a method argument
  3. DataRead and write. ORM, a scope, or a Repository — when there is already a reason
Entry point, operation, data. Each arrow does not need its own class.

In Yii2 a Form checks input fields; Search builds list filters, it is not POST validation. The same Form can load() from CLI. In Laravel, FormRequest is HTTP-only; a command uses the same rules() through Validator, not a fake Request. Action in the table below is a use-case class, not actionCreate and not a Laravel invokable controller. Different names, same roles.

What is enough, and when it is not

The table is a guide. No row for your case — you still have to name the pain the new class closes.

Task Enough Add complexity when
Create or update from the API Form / FormRequest → service method with scalars The field list is already awkward, or several places assemble the same input → DTO
Filtered list Search or scopes plus pagination The same filters and access constraint are copied around → a shared scope or Repository
Input fragment with its own rule rules() or a shared validator You need a type with its own valid state → VO. Repeated rules are a signal, not a duty to add a VO
Several operations in one area One Service with short methods One scenario grew its own dependencies and breaks the other methods → Action
API response fields(), a Resource, or an explicit array The public response starts to live apart from storage → a Resource or an array built in the adapter
Queue The Job calls the same Service The result can wait. A retry means the broker gave the same Job out again. Partial success means part already ran, and retrying the whole Job breaks it. Push after the commit; if the Job must not be lost — a row in the same transaction and a sender (outbox). DTO and Action not because this is a queue
Cache No cache, read from the DB You measured an expensive repeated read and an acceptable staleness. Invalidate after a successful commit, not in the controller

Scalars are enough

A simple create: the Form validated the fields, the controller took userId from the session and called the service. No DTO yet. Input errors come from the adapter, not the service.

PHP 8.1+ · Form validated, service got scalars
$form->load($request->post(), '');
if (!$form->validate()) {
    Yii::$app->response->statusCode = 422;
    return ['errors' => $form->getErrors()];
}

$item = $this->items->create(
    userId: $userId,
    name: $form->name,
);

load($data, '') is a flat body. Without the second argument Yii expects a key named after the form. In Laravel, for a request that expects JSON, FormRequest answers 422. A second adapter alone does not justify an object: two clear arguments can be passed from CLI too.

When the field list is awkward

Awkward to thread means the signature stopped being readable, not that there are “many arguments”. Then one object. This is not a Form: no rules(), and a Service may take it.

PHP 8.1+ · fields in one object, no rules()
final class CreateItemInput
{
    public function __construct(
        public readonly int $userId,
        public readonly string $name,
        public readonly ?string $note,
    ) {}
}

When a value keeps its own rule

The form said OK, and the check stopped there. After that the data keeps moving through the code, and it can still be changed so the rule no longer holds. A value class does not create that object: the invariant lives in the constructor, and the fields cannot be changed. You pass the VO on, not the scalars.

Add a VO when you need a standalone value and its valid state further down the code. Repeated rules in API, CLI and the service help you notice that; repetition alone does not require a VO — a shared validator may be enough. One scenario can still justify a type if the value has its own rules and operations.

A value from the request is convenient to build before calling the service: the error is easier to attach to a field. A value computed during the operation is built inside the service. Turn an expected input error into a response yourself. Where the exception was thrown does not set the HTTP status. Do not wrap every field.

What not to do

If a pattern does not make the code easier to read and does not remove duplication, it is not needed yet. What follows is a convention of this approach. You can pass a Form into a Service; then the service depends on the framework’s validation model and its mutable state. A FormRequest already depends on HTTP.

  • Do not pass a Form or FormRequest into a Service.
  • A class that extends Model, has rules(), and is named *Dto is a Form under another name. Rename it.
  • A Repository with the same find/save as the ORM is a rename, not a layer.
  • A Service that only proxies a Repository and adds nothing is not needed.
  • Do not copy every Form rules() into an input DTO just in case.

Who may access the record is not in the input

In HTTP the client sends JSON and query params. Who they are comes from the session or a token. That id is passed into the Service as an argument. An id in the request is a claim, not permission. A list with no id is still scoped by who is logged in, not by what the client sent. An id match in an if does not replace a query constraint. A validated int changes nothing.

On a Job the payload stores on whose behalf the operation was requested. That is not permission to act as that party. Check rights at enqueue, at execute(), or at both — that choice has to be made. Jobs are not only pushed from HTTP: cron, an event, another Job. The worker is a console process; there is no HTTP session. The application’s “current user” will be empty. Use what we wrote on the Job. A requestId from the boundary goes on the Job and into the log; execute() writes that id, not the worker’s “current request”.

In the console a parameter says on whose behalf the operation was requested; permission to act as that party is checked separately. Cron may run under a system role, with no user.

Do not leave “may we?” only in the controller: the console and the queue will not run that code. If the scenario is shared, the check lives next to it, in the Service. Who it is comes from the session, the Job payload, a cron parameter, or a system role, as a method argument.

PHP 8.1+ · userId on the Job; Yii2 execute($queue), Laravel handle()
public readonly int $userId;

// Yii2 Queue
public function execute($queue): void
{
    Yii::$container->get(ReportService::class)
        ->export($this->userId);
}

// Laravel
public function handle(ReportService $reports): void
{
    $reports->export($this->userId);
}

Layers and tests

If the scenario got simpler, remove the Action and the DTO. Removing a layer already called from three places hurts more than not adding it.

In tests I keep the layers apart. A value invariant is constructing the object, without HTTP: if that needs a request, the rule is not in the VO. Access constraints and writes hit the database; a repository mock does not prove WHERE or unique. Status and error text live in the controller or command: the service says “no”, the adapter sets 422 or the exit code.

AGENTS.md for your project

Two self-contained AI agent instruction templates: the principles in this article plus framework-specific guidance. Both files are in English; each downloads as AGENTS.md.

Before adoption, merge the template with existing project instructions, check its conventions and record actual verification commands. It does not prescribe a directory layout or require every construct described above.

← Home