Design Patterns And Data Architecture
Service Pattern
Service is one of the most overloaded words in PHP application design. A service can mean a class registered in a dependency injection container, a business operation, an external provider client, a domain rule that does not fit one entity, or a vague dumping ground named UserService. The word is useful only when the responsibility is clear.
In this lesson, a service is a class that performs a meaningful operation for the application and does not naturally belong in a controller, template, entity, value object, repository, or adapter. Services help keep controllers thin, keep domain rules testable, and make workflows explicit. They also become harmful when they collect unrelated behavior behind broad names.
PHP frameworks encourage service classes in different ways. Symfony uses services as container-managed objects. Laravel applications often create service classes, action classes, jobs, listeners, and domain classes depending on style. The pattern is not tied to one framework. The engineering question is: what job does this class own?
Types Of Services
It helps to separate service types:
- an application service coordinates a use case
- a domain service holds domain logic that involves several objects or concepts
- an infrastructure service talks to external systems or technical resources
- a framework service is any object managed by the container
These categories can overlap in casual conversation, but they should not blur in code. A RegisterUser application service may use a PasswordPolicy domain service, a UserRepository, and an EmailVerifier infrastructure service. Each owns a different kind of work.
The name should reveal the job. UserService is often too broad. RegisterUser, DeactivateDormantAccounts, CalculateInvoiceTotal, or SendPasswordResetLink gives reviewers a better starting point.
An Application Service
An application service coordinates a use case. It receives input, loads required data, calls domain behavior, saves changes, and triggers application side effects through dependencies.
<?php
declare(strict_types=1);
final readonly class User
{
public function __construct(
public int $id,
public string $email,
public bool $active,
) {
}
}
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 . '.';
}
}
final class InMemoryUserRepository implements UserRepository
{
public function findActiveByEmail(string $email): ?User
{
return $email === 'ada@example.com' ? new User(42, $email, true) : null;
}
}
$login = new LoginUser(new InMemoryUserRepository());
echo $login->handle('ada@example.com') . PHP_EOL;
// Prints:
// Login allowed for user 42.
LoginUser is not a controller. It does not read $_POST, choose a redirect, render a template, or set cookies. It coordinates the application operation after input has been extracted.
A real login flow would return a richer result and integrate password verification and session handling. The example keeps the service boundary visible.
Domain Services
A domain service holds business logic that does not naturally belong to one entity or value object. For example, calculating shipping across a basket, destination, carrier rules, and promotion calendar may not belong inside only Order or only Address.
Domain services should use domain language and avoid HTTP, SQL, framework request objects, and provider SDK details. If a class named TaxCalculator reads query parameters and sends an HTTP response, it is not a clean domain service.
Prefer putting behavior on the entity or value object when the behavior clearly belongs there. Do not create a service just to avoid methods on domain objects. A Money object can add money. An Order can know whether it is already cancelled. A domain service is for behavior that crosses several concepts.
Infrastructure Services
Infrastructure services wrap technical capabilities such as email delivery, storage, queues, HTTP clients, clocks, UUID generation, hashing, search indexing, and payment providers. They are often adapters, gateways, or clients.
Keep infrastructure services behind interfaces when the application needs a stable boundary. ReceiptSender is clearer than passing a provider SDK through application services. The service implementation can know provider details; the application service should know the application-level operation.
Do not let infrastructure services decide business policy. A mailer should not decide whether a customer is eligible for a refund. It should send a message when the use case tells it to.
Service Names Matter
A vague service name is a design smell. UserService often starts with one method and grows into registration, login, password reset, profile updates, admin deactivation, email verification, import, export, and reporting. The class becomes hard to test because every dependency accumulates in one constructor.
Use names that describe one use case or one cohesive capability. RegisterUser, ChangeUserEmail, and DeactivateUser are easier to review than UserService. If several operations share a small domain rule, extract that rule into a focused service such as EmailChangePolicy rather than keeping a large general service.
A class can still have more than one method when the methods belong together. For example, a PasswordHasher may have hash() and verify(). The problem is not method count alone; it is unrelated responsibility.
Service Versus Action
Action classes are often single-use-case services with one entry method. Some teams use RegisterUserAction; others use RegisterUser; others use command handlers. The naming convention matters less than consistency and responsibility.
Use services when a capability naturally has a small set of related operations or when the codebase already uses the service vocabulary. Use actions when the team wants one class per use case. Do not create both RegisterUserService and RegisterUserAction for the same operation unless they have distinct responsibilities.
The important review question is: can a developer tell where the use case starts, what dependencies it needs, and what result it returns?
Inputs And Outputs
Services should avoid accepting raw framework requests unless their job is specifically to adapt HTTP. Passing a request object deep into application services spreads HTTP concerns. Prefer explicit input values or input DTOs.
A service should also return a clear result. Returning mixed arrays can become another dumping ground. Use a value object, enum-backed result, exception, or simple scalar when appropriate. The caller should be able to distinguish success, validation rejection, permission denial, conflict, temporary failure, and unexpected defect where those outcomes matter.
Do not hide every failure behind false. That forces callers to guess and makes logs less useful.
Service Lifetime
Service lifetime matters in modern PHP runtimes. Under traditional PHP-FPM, many objects are rebuilt often enough that accidental per-request state may disappear quickly. Under workers, queues, application servers, or long-running command processes, the same service instance may handle many jobs or requests. A service that stores current user, request locale, temporary form data, or last processed record in object properties can leak state across work.
Prefer stateless services unless state is part of the class's explicit responsibility. Pass request-specific values as method arguments or immutable input objects. If a framework container supports singleton and per-request scopes, choose the narrower lifetime for services that depend on request context. Tests should include a second call with different input when state leakage would be dangerous.
Transactions And Side Effects
Application services often own transaction boundaries because they know the complete use case. If registering a user requires saving a user, writing an outbox event, and committing before an email worker sends a message, the application service or command handler should coordinate that sequence.
Avoid sending external emails or payment calls inside a database transaction unless the design deliberately handles partial failure. External systems do not roll back with your database. Use outbox patterns, jobs, or clear compensation rules when needed.
A service should make ordering visible. Reviewers should be able to answer what remains true if the process crashes after each step.
Testing Services
Application services are good test targets. Inject fake repositories, clocks, hashers, notifiers, and providers. Test success, rejection, missing data, duplicate input, permission denial, temporary dependency failure, and side-effect decisions.
Domain services should be testable with plain objects and value objects. If a domain service needs a framework container or database connection, its responsibility is probably mixed.
Infrastructure services need integration tests against the real provider, SDK fake, sandbox, or local equivalent. A fake mailer proves the application requested email; it does not prove provider payload mapping.
Common Failure Modes
One failure mode is the god service. A broad UserService accumulates unrelated operations and dependencies. Split by use case or cohesive capability.
Another failure mode is moving controller code unchanged into a service. If the service still reads request fields, sets session messages, returns HTML, and catches framework exceptions, the boundary has not improved.
A third failure mode is an anemic service that only calls one repository method with the same arguments. If it adds no rule, transaction, side effect, or naming clarity, direct use may be simpler.
A fourth failure mode is using service as a synonym for every class. Repositories, adapters, factories, decorators, and domain objects have more precise names. Use them when they fit.
Review Criteria
When reviewing a service, ask what job it owns in one sentence. Check that its constructor dependencies match that job. If a service needs ten unrelated dependencies, the class probably owns too much.
Check inputs and outputs. They should use application language and avoid leaking framework request objects, ORM query builders, provider SDK responses, or raw arrays where a clearer contract is needed.
Check transaction and side-effect order. A service that coordinates writes should make durable state and external effects understandable. Check tests for both normal and failure paths.
What To Check
Before moving on, make sure you can:
- distinguish application, domain, infrastructure, and framework services
- name services after focused responsibilities
- avoid vague
UserServicedumping grounds - keep HTTP, SQL, provider, and template concerns out of domain services
- choose between service, action, command handler, repository, and adapter vocabulary
- define clear service inputs and outputs
- test service behavior with appropriate fakes and integration checks
After this lesson, you should be able to look at a PHP class named service and decide whether it has a real cohesive responsibility, should be split, should be renamed to a more precise pattern, or should not exist at all.
Practice
Task: Classify Service Responsibility
Classify each class as application service, domain service, infrastructure service, or not a good service boundary.
Classes
RegisterUserloads a user repository, hashes a password, saves the user, and records an outbox event.ShippingCostCalculatorcalculates cost from basket weight, destination, and carrier rules using plain value objects.SendGridReceiptSendertranslates receipt data into a provider API request.UserServicehandles registration, admin reports, profile updates, imports, exports, and password resets.
Requirements
Explain each classification and one improvement for any weak boundary.
Check your work
The answer should not treat every class as the same kind of service.
Show solution
RegisterUser is an application service. It coordinates a use case across hashing, persistence, and event recording. It should own use-case ordering and return a clear result.
ShippingCostCalculator is a domain service if the rule does not naturally belong to one entity. It should stay free of HTTP request objects, SQL, and provider SDK details.
SendGridReceiptSender is an infrastructure service or adapter. It translates application receipt data into provider-specific API calls. The application should depend on a provider-neutral interface such as ReceiptSender.
UserService is not a good boundary as described. It owns unrelated responsibilities and will accumulate too many dependencies. Split it into focused classes such as RegisterUser, ChangeUserProfile, ResetPassword, and ImportUsers, plus shared smaller domain services where real shared rules exist.
Task: Split A User Service
A UserService class has methods register(), changeEmail(), resetPassword(), exportCsv(), and deactivateDormantUsers().
Propose a clearer design.
Requirements
Name the new classes, describe what dependencies each should receive, and identify any shared rule that might become a domain service.
Check your work
The answer should avoid replacing one god service with another broad manager class.
Show solution
Split by use case. RegisterUser can receive a user repository, password hasher, clock, and outbox or event recorder. ChangeUserEmail can receive the repository and an email-change policy. ResetPassword can receive token storage, password hasher, clock, and notifier.
ExportUsersCsv is a reporting/export use case. It may use a read repository and CSV writer rather than the same dependencies as registration. DeactivateDormantUsers may be a scheduled application service that receives a repository, clock, and event recorder.
A shared rule such as valid email-change timing can become EmailChangePolicy. A shared formatter or hasher can be its own infrastructure or domain service. Do not introduce UserManager; that would recreate the same broad responsibility under a new name.
Task: Test An Application Service
A RegisterUser service validates an email, hashes a password, saves a user, and records an outbox event for verification email.
Plan tests for the service.
Requirements
Include success, invalid email, duplicate email, hasher failure, repository failure, and outbox behavior. State which dependencies should be faked.
Check your work
The answer should test the use case without sending a real email or requiring a real provider.
Show solution
Use fakes for the user repository, password hasher, clock, and outbox recorder. The success test should verify that a valid user is saved with a hashed password and that a verification event is recorded after the user exists.
The invalid email test should return or throw a validation result before hashing or saving. The duplicate email test should prove the service checks the repository or handles a unique constraint outcome and does not record an outbox event.
The hasher failure test should prove no user is saved. The repository failure test should prove no success result is returned and no verification event is recorded unless the design uses an atomic transaction or outbox mechanism that guarantees consistency.
Outbox behavior should be asserted as a recorded application event, not a real email send. A separate worker or notifier integration test can prove provider delivery.