Beyond Abstraction: Building PHP Services That Actually Work
Most PHP services do not fail because PHP is incapable. They fail because the codebase becomes more loyal to abstraction than to the work the service must actually perform: accepting input, protecting data, calling dependencies, surviving partial failure, and giving operators enough evidence to act.
Good backend engineering is not a contest to produce the most layers. It is the discipline of making important behavior easy to find, easy to test, and difficult to misuse. PHP is entirely capable of supporting that discipline when its boundaries are chosen with care.
Start with a useful boundary
A service boundary should correspond to a meaningful responsibility, not a fashionable pattern. An endpoint that creates an order, for example, needs validation, an authorization decision, a database transaction, and perhaps a message for downstream work. Those are real concerns. Introducing a generic “manager,” “handler,” “factory,” and “provider” for each one may not clarify them.
A practical shape is often simple: keep HTTP concerns in a controller, place the use case in an application service, and isolate persistence or external systems behind small interfaces. The core use case should read like the business operation it represents.
final class CreateOrder
{
public function __construct(
private OrderRepository $orders,
private TransactionManager $transactions,
private OrderEvents $events,
) {}
public function handle(CreateOrderCommand $command): Order
{
return $this->transactions->run(function () use ($command): Order {
$order = Order::fromCommand($command);
$this->orders->save($order);
$this->events->record(new OrderCreated($order->id()));
return $order;
});
}
}
This is not “clean” because it has interfaces. It is useful because the transaction, persistence, and event intent are visible. A reader can ask the right operational question immediately: how are recorded events delivered after the transaction commits?
Make failure a first-class design input
Happy-path code is usually short. Production behavior lives in timeouts, retries, duplicate requests, unavailable databases, expired credentials, and messages delivered more than once. These are not exceptional details to append later; they shape the design from the beginning.
Consider a request that charges a customer and creates an order. A database transaction cannot atomically include a remote payment provider. If the charge succeeds and the database write fails, retrying blindly could charge the customer again. The answer is not a larger transaction. It is an explicit idempotency strategy: accept a client-provided key, store the result associated with that key, and ensure the same key returns the original outcome.
Likewise, publishing an event directly after a database commit has a gap. The process can stop after committing the order but before publishing the event. An outbox table closes that gap: write the order and an outbox record in one database transaction, then let a separate worker deliver pending records. Delivery must still be idempotent, because a worker may send a message successfully and fail before marking it complete.
- Set timeouts on outbound HTTP and database connections.
- Retry only failures that are plausibly transient, with bounded attempts.
- Use idempotency keys for externally visible commands.
- Record enough context to investigate failures without logging secrets.
- Design consumers to tolerate duplicate messages and reordered delivery where applicable.
Keep the database model honest
ORMs can accelerate ordinary persistence, but they do not remove database behavior. A query inside a loop is still a query inside a loop. A missing index is still a missing index. A transaction held open while calling another service is still a source of contention and failure.
Model constraints where they belong. If an email address must be unique, enforce that with a unique database constraint, then translate the resulting conflict into a useful application response. Application-side checks improve the user experience, but concurrent requests can pass the same check. Only the database can enforce the invariant at the point of write.
For important reads, inspect the query that actually runs and its execution plan. Prefer pagination that matches the access pattern. Offset pagination may be fine for small administrative lists; for a large, frequently changing feed, keyset pagination based on a stable, indexed ordering is often more predictable.
Transactions should be short and intentional
Use transactions to preserve related local changes, not as a blanket around an entire request. Validate inputs and perform slow remote calls before opening a transaction when possible. Inside the transaction, make the essential writes, enforce invariants, and commit. This keeps locks short and makes contention easier to reason about.
Containers should reduce surprises
Docker helps when the container definition makes runtime assumptions explicit. A production image should contain the application and only the runtime dependencies it needs. Build tools, test tooling, and development configuration usually belong in a separate build stage or local development setup.
FROM php:8.3-cli AS runtime
WORKDIR /app
COPY . /app
CMD ["php", "bin/worker.php"]
This example is deliberately small, not a universal production image. A real service also needs a deliberate dependency-installation step, a non-root runtime user where appropriate, configuration supplied through its deployment environment, and a health strategy that reflects what the process can truly serve. Do not make a health check depend on a slow third-party API unless that dependency is required for every request.
Configuration should be validated at startup. Failing early because a required database URL is absent is safer than accepting traffic and failing every request. Keep secrets out of source control, logs, exception messages, and container image layers.
Observability is part of the interface
A service that cannot explain what it is doing is expensive to operate. Logs should be structured enough to filter and correlate. Include a request or trace identifier, operation name, outcome, and safe identifiers such as an order ID. Avoid dumping full request bodies, authorization headers, passwords, tokens, or payment details.
Metrics should answer operational questions: how many requests fail, how long key operations take, whether queues are growing, and whether dependency calls are timing out. Alerts should be tied to user-impacting symptoms or clear capacity risks, not every exception that a retry successfully resolved.
Choose boring clarity over ceremonial architecture
Abstractions earn their place when they hide volatile details, create a stable testing seam, or make a repeated rule easier to enforce. They do not earn it merely by making a directory tree look enterprise-ready.
The strongest PHP services are rarely mysterious. Their requests have clear paths, their data has enforceable rules, their dependencies have failure behavior, and their deployment has explicit assumptions. Build for those realities. When the system is under pressure, clarity is not aesthetic polish; it is the feature that keeps the service working.