Development

Beyond CRUD: Architecting PHP Services for Tenacity

Beyond CRUD: Architecting PHP Services for Tenacity

CRUD is where most backend services begin, not where they prove themselves. Creating, reading, updating, and deleting records can demonstrate a framework, but production systems are judged by harder questions: What happens when a dependency stalls? When a client retries? When two requests modify the same resource? When a deployment changes an assumption nobody wrote down?

A durable PHP service is not one with the most layers. It is one whose important behavior remains understandable under load, partial failure, change, and routine maintenance. The architecture should make the happy path clear, but it should make the unhappy paths deliberate.

Start with boundaries, not folders

A familiar directory structure can create the appearance of architecture without providing real separation. The more useful question is: which code is allowed to know about which detail?

An HTTP controller should translate an incoming request into an application use case and translate its result into a response. It should not decide database queries, calculate business rules, or quietly call a third-party API. Likewise, domain decisions should not depend on request objects, ORM models, or framework helpers.

A practical service often has three broad areas:

  • Transport: controllers, request validation, authentication middleware, and response formatting.
  • Application: use cases that coordinate work, define transaction boundaries, and enforce workflow rules.
  • Infrastructure: database repositories, queues, caches, mail providers, and external HTTP clients.

This is not an argument for ceremonial abstractions around every class. A repository interface is worthwhile when it protects meaningful application logic from persistence details or enables a useful alternative implementation. Wrapping a single ORM call merely because “repositories are cleaner” often adds indirection without reducing risk.

Make state changes explicit and transactional

Many bugs arrive when a service treats a business operation as a series of unrelated writes. Consider placing an order: reserve inventory, create the order, and record an audit event. If inventory is decremented but order creation fails, the system has created a state it cannot easily explain.

Put changes that must succeed or fail together inside a database transaction. Keep that transaction small: lock only what is necessary, avoid network calls while holding it open, and return a result that represents the completed state.

final class PlaceOrder
{
    public function __construct(
        private Connection $db,
        private Inventory $inventory,
        private Orders $orders,
        private Outbox $outbox,
    ) {}

    public function handle(PlaceOrderCommand $command): Order
    {
        return $this->db->transaction(function () use ($command): Order {
            $this->inventory->reserve($command->productId, $command->quantity);

            $order = $this->orders->create(
                customerId: $command->customerId,
                productId: $command->productId,
                quantity: $command->quantity,
            );

            $this->outbox->record('order.placed', ['order_id' => $order->id]);

            return $order;
        });
    }
}

The outbox entry matters. Sending a message directly to a broker after committing can fail, leaving the order stored but downstream systems unaware. Sending before committing can publish an event about a transaction that later rolls back. Recording the event with the database change gives a worker a reliable item to publish later. The worker must still tolerate duplicate publication, but the system has a recoverable path.

Design APIs for retries and disagreement

Networks are unreliable in ordinary ways. A client can time out after the server successfully creates a resource. If the client retries a non-idempotent request, the service may create two orders, payments, or notifications.

For operations where duplication is harmful, accept an idempotency key and store it with the completed result. Repeating the same key should return the original outcome rather than perform the operation again. Define what happens if the same key is reused with different input: usually reject it, because silently accepting conflicting intent makes support incidents much harder to resolve.

Concurrency deserves equal attention. A “check then update” sequence can oversell inventory if two requests both observe stock before either writes. Use the database as the concurrency authority: a suitable row lock, an atomic conditional update, or an optimistic version check can make the rule enforceable. The correct choice depends on contention and the cost of a retry, but relying on application timing is not a choice at all.

Errors are part of the contract

A service should distinguish invalid input, missing resources, forbidden actions, conflicts, and temporary dependency failures. Clients cannot behave well if every problem becomes a generic server error.

Keep error responses stable and safe. Return a machine-readable code, a human-readable message appropriate for the caller, and a request identifier when available. Log the internal context separately. Database details, stack traces, credentials, and third-party response bodies generally belong nowhere near a public API response.

Use asynchronous work without hiding accountability

Queues are excellent for work that need not complete before the HTTP response: email, image processing, webhook delivery, search indexing, and fan-out notifications. They are not a substitute for deciding what the user is promised.

If an endpoint returns success before queued work finishes, its contract should say that the request was accepted, not that every downstream effect has occurred. Jobs need clear retry behavior: classify transient failures, use bounded retries with delay, and send exhausted jobs somewhere operators can inspect. A queue that endlessly retries a permanent validation error is not resilient; it is simply noisy.

Consumers should be idempotent too. Delivery may happen more than once, especially around worker crashes and acknowledgement timing. Store a processed message identifier or make the resulting write naturally safe to repeat.

Keep PHP operationally boring

PHP services benefit from boring deployment conventions. Build one immutable application artifact, provide configuration through the environment or a managed configuration system, run database migrations as a controlled deployment step, and make the process observable. Containers help when they make local, test, and production environments more consistent; they do not remove the need to manage database connections, file permissions, timeouts, or graceful shutdown.

For HTTP clients, set explicit connection and overall timeouts. A dependency that hangs should not consume PHP workers indefinitely. Reuse connections when the runtime and client support it, but set limits on downstream concurrency so a struggling provider does not pull the whole service into a cascade.

Performance work should follow measurement. N+1 queries, missing indexes, oversized payloads, and unbounded pagination are common because they are easy to introduce and hard to notice in small datasets. Add query visibility, define pagination limits, and examine the slow path before reaching for a cache. Caching helps only when its invalidation and staleness rules are understood.

Build for the next change

Maintainability is mostly the ability to change one rule without guessing which unrelated behavior will break. Name use cases after business actions, keep configuration centralized, test boundary behavior, and make observability a feature rather than an emergency add-on. Structured logs, health checks, metrics, and traces should answer what happened without requiring a production shell session.

The goal is not a perfect architecture diagram. It is a service that can absorb ordinary failure, communicate its state honestly, and evolve without turning each small request into a risky expedition. CRUD may be the surface area of a system. Tenacity is what makes it worth running.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.