objects namespaces and application architecture

Repository, Service, Action, and Command-Handler Patterns

Repository, service, action, and command-handler are common names for classes that organise application code. They are not magic names. Each one is useful only when it gives a class a clear responsibility.

PHP projects often start with controllers doing too much. A controller method receives a request, validates it, runs SQL, applies business rules, sends notifications, and returns a response. These patterns give you places to move that work so each class is easier to read, test, and change.

The important skill is not choosing the most impressive word. The important skill is knowing what responsibility the class owns.

Repository

A repository hides storage details behind methods that make sense to the application.

Instead of spreading SQL or ORM queries across controllers, a repository gathers the persistence logic for a concept such as users, orders, invoices, lessons, or subscriptions.

PHP example
<?php

declare(strict_types=1);

final readonly class User
{
    public function __construct(
        public int $id,
        public string $email,
        public bool $isActive,
    ) {
    }
}

interface UserRepository
{
    public function findActiveByEmail(string $email): ?User;
}

final class InMemoryUserRepository implements UserRepository
{
    /** @var list<User> */
    private array $users;

    public function __construct(User ...$users)
    {
        $this->users = $users;
    }

    public function findActiveByEmail(string $email): ?User
    {
        foreach ($this->users as $user) {
            if ($user->email === $email && $user->isActive) {
                return $user;
            }
        }

        return null;
    }
}

The repository decides how users are found. In production, the implementation might use PDO, Doctrine, Eloquent, or an external service. The application code should not need to know those details.

Repository methods should usually be named after application needs, not database mechanics. findActiveByEmail() says more than whereEmailAndStatus().

Service

Service is a broad word. In many PHP projects, a service is a class that performs a meaningful operation and does not naturally belong on a single entity.

Application services coordinate use cases. Domain services hold business rules that involve multiple domain objects. Infrastructure services talk to external systems.

PHP example
<?php

declare(strict_types=1);

final readonly class User
{
    public function __construct(
        public int $id,
        public string $email,
        public bool $isActive,
    ) {
    }
}

interface UserRepository
{
    public function findActiveByEmail(string $email): ?User;
}

final class LoginUser
{
    public function __construct(
        private UserRepository $users,
    ) {
    }

    public function handle(string $email): string
    {
        $user = $this->users->findActiveByEmail($email);

        if ($user === null) {
            return 'Login denied.';
        }

        return 'Login allowed for user ' . $user->id . '.';
    }
}

LoginUser is an application service because it coordinates a use case. It asks the repository for data, applies an application decision, and returns a result.

Be careful with vague service names. UserService, OrderService, or ManagerService can become dumping grounds. A name such as RegisterUser, CancelSubscription, or CalculateInvoiceTotal usually communicates the job better.

Action

An action is often a single-purpose class that performs one use case. In some teams, an action is similar to an application service but intentionally narrower.

Actions are common in Laravel and other PHP projects where developers want controller methods to delegate immediately.

PHP example
<?php

declare(strict_types=1);

final class PublishArticleAction
{
    public function execute(Article $article): void
    {
        if ($article->isPublished) {
            throw new RuntimeException('Article is already published.');
        }

        $article->isPublished = true;
    }
}

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

$article = new Article('Object boundaries');
$action = new PublishArticleAction();

$action->execute($article);

echo $article->isPublished ? 'published' : 'draft';
echo PHP_EOL;

// Prints:
// published

An action should normally have one obvious entry method, often named execute(), handle(), or __invoke(). Pick the naming style your codebase already uses.

Command And Command Handler

A command is a small object describing something the application should do. A command handler receives that command and performs the work.

This pattern is useful when a use case needs a clear input object, when commands are dispatched through a bus, or when the same operation can be triggered by HTTP, CLI, queue workers, or scheduled jobs.

PHP example
<?php

declare(strict_types=1);

final readonly class ChangeEmailCommand
{
    public function __construct(
        public int $userId,
        public string $newEmail,
    ) {
    }
}

final class ChangeEmailHandler
{
    public function handle(ChangeEmailCommand $command): string
    {
        if (!filter_var($command->newEmail, FILTER_VALIDATE_EMAIL)) {
            return 'Email address is not valid.';
        }

        return 'Changed email for user ' . $command->userId . '.';
    }
}

$handler = new ChangeEmailHandler();

echo $handler->handle(new ChangeEmailCommand(42, 'ada@example.com')) . PHP_EOL;

// Prints:
// Changed email for user 42.

The command carries input. The handler owns the behaviour. This separation can be helpful, but it is overkill for very small code where a simple method call is already clear.

How These Patterns Work Together

A real feature may use several of these patterns together:

  • a controller receives the HTTP request
  • a command object carries the validated input
  • a command handler or action coordinates the use case
  • a repository loads and saves data
  • infrastructure services send email, publish events, or call APIs

Here is a small example:

PHP example
<?php

declare(strict_types=1);

final class Subscription
{
    public function __construct(
        public int $id,
        public bool $isCancelled = false,
    ) {
    }

    public function cancel(): void
    {
        if ($this->isCancelled) {
            throw new RuntimeException('Subscription is already cancelled.');
        }

        $this->isCancelled = true;
    }
}

interface SubscriptionRepository
{
    public function findById(int $id): ?Subscription;

    public function save(Subscription $subscription): void;
}

final readonly class CancelSubscriptionCommand
{
    public function __construct(
        public int $subscriptionId,
    ) {
    }
}

final class CancelSubscriptionHandler
{
    public function __construct(
        private SubscriptionRepository $subscriptions,
    ) {
    }

    public function handle(CancelSubscriptionCommand $command): string
    {
        $subscription = $this->subscriptions->findById($command->subscriptionId);

        if ($subscription === null) {
            return 'Subscription was not found.';
        }

        $subscription->cancel();
        $this->subscriptions->save($subscription);

        return 'Subscription cancelled.';
    }
}

The handler does not know whether subscriptions are stored in MySQL, PostgreSQL, Redis, or memory. The repository hides that detail. The command gives the handler a clear input type.

Choosing The Right Shape

Use a repository when you need to hide persistence or query details.

Use an application service or action when a use case is too important or too large to live in a controller.

Use a command handler when a command object gives the operation a clear input shape, or when your framework uses a command bus.

Use a plain method when the code is already simple. Patterns should remove confusion, not create ceremony.

What To Watch For

The biggest mistake is vague naming. UserService::process() tells another developer almost nothing. RegisterUser::handle() or CancelSubscriptionHandler::handle() tells them the use case immediately.

Another common mistake is putting SQL inside the action or handler because it is "only one query". That may be fine in a tiny script, but in an application with established repository boundaries it makes later changes harder.

Also watch for classes that only pass data through without adding meaning. If an action just calls one repository method with the same arguments, the extra class may not be earning its place yet.

What You Should Be Able To Do

After this lesson, you should be able to read a PHP project and understand why a class is called a repository, service, action, or command handler. You should be able to place new code in the right kind of class, name it after its responsibility, and avoid turning Service into a vague bucket for unrelated behaviour.

Practice

Practice: Cancel A Subscription With A Command Handler

Build a small PHP example for cancelling a subscription.

Task

Create:

  • a Subscription domain object with a cancel() method
  • a SubscriptionRepository interface
  • an in-memory repository implementation
  • a CancelSubscriptionCommand
  • a CancelSubscriptionHandler

The handler should:

  • return a useful message when the subscription does not exist
  • cancel an active subscription
  • reject cancelling an already cancelled subscription
  • save the changed subscription through the repository

Use strict types. Keep the output in the same code block as printed lines or comments.

Check Your Work

Run cases for:

  • cancelling an existing active subscription
  • cancelling a missing subscription
  • cancelling the same subscription twice

Afterward, explain which class is the repository, which class is the command, and which class is the command handler.

Show solution

This solution keeps the domain rule on Subscription, storage behind SubscriptionRepository, and use-case coordination inside CancelSubscriptionHandler.

PHP example
<?php

declare(strict_types=1);

final class Subscription
{
    public function __construct(
        public int $id,
        private bool $cancelled = false,
    ) {
    }

    public function isCancelled(): bool
    {
        return $this->cancelled;
    }

    public function cancel(): void
    {
        if ($this->cancelled) {
            throw new RuntimeException('Subscription is already cancelled.');
        }

        $this->cancelled = true;
    }
}

interface SubscriptionRepository
{
    public function findById(int $id): ?Subscription;

    public function save(Subscription $subscription): void;
}

final class InMemorySubscriptionRepository implements SubscriptionRepository
{
    /** @var array<int, Subscription> */
    private array $subscriptions = [];

    public function __construct(Subscription ...$subscriptions)
    {
        foreach ($subscriptions as $subscription) {
            $this->subscriptions[$subscription->id] = $subscription;
        }
    }

    public function findById(int $id): ?Subscription
    {
        return $this->subscriptions[$id] ?? null;
    }

    public function save(Subscription $subscription): void
    {
        $this->subscriptions[$subscription->id] = $subscription;
    }
}

final readonly class CancelSubscriptionCommand
{
    public function __construct(
        public int $subscriptionId,
    ) {
    }
}

final class CancelSubscriptionHandler
{
    public function __construct(
        private SubscriptionRepository $subscriptions,
    ) {
    }

    public function handle(CancelSubscriptionCommand $command): string
    {
        $subscription = $this->subscriptions->findById($command->subscriptionId);

        if ($subscription === null) {
            return 'Subscription was not found.';
        }

        try {
            $subscription->cancel();
        } catch (RuntimeException $exception) {
            return $exception->getMessage();
        }

        $this->subscriptions->save($subscription);

        return 'Subscription cancelled.';
    }
}

$repository = new InMemorySubscriptionRepository(
    new Subscription(100),
);

$handler = new CancelSubscriptionHandler($repository);

echo $handler->handle(new CancelSubscriptionCommand(100)) . PHP_EOL;
echo $handler->handle(new CancelSubscriptionCommand(999)) . PHP_EOL;
echo $handler->handle(new CancelSubscriptionCommand(100)) . PHP_EOL;

// Prints:
// Subscription cancelled.
// Subscription was not found.
// Subscription is already cancelled.

InMemorySubscriptionRepository is the repository because it owns storage and lookup. CancelSubscriptionCommand is the command because it carries the input for the operation. CancelSubscriptionHandler is the command handler because it receives the command, loads the subscription, runs the domain rule, saves the change, and returns the result.