Отфрлете го CRUD: Архитектирање на PHP услуги со долгорочна визија
CRUD is a useful starting point and a poor destination. Create, read, update, and delete operations map neatly to tables, forms, and early API endpoints. They also encourage a subtle architectural mistake: treating a business system as a thin wrapper around a database.
That approach works until the software acquires rules, exceptions, integrations, retries, permissions, and a second team member who needs to change it safely. Then endpoints such as POST /orders and PATCH /orders/42 begin collecting validation, pricing, stock checks, emails, audit entries, and third-party calls. The controller becomes a traffic jam, and every new feature risks changing behavior somewhere unrelated.
Long-lived PHP services need a design centered on meaningful actions, explicit boundaries, and failure-aware workflows. The database remains important, but it should support the application’s behavior rather than define it.
Model the work, not just the records
A CRUD API asks, “How do I update this order row?” A service-oriented design asks, “What does it mean to place, confirm, cancel, or refund an order?” Those questions produce different code and, more importantly, different constraints.
Consider an order that may be edited while it is a draft but must not change after payment is captured. A generic update endpoint invites callers to send arbitrary fields. A command such as confirmOrder makes the transition visible and gives one place to enforce its rules.
final class ConfirmOrder
{
public function __construct(
private OrderRepository $orders,
private PaymentGateway $payments,
private TransactionManager $transactions,
) {}
public function handle(OrderId $orderId): void
{
$this->transactions->run(function () use ($orderId): void {
$order = $this->orders->get($orderId);
if (!$order->canBeConfirmed()) {
throw new DomainException('Order cannot be confirmed.');
}
$order->confirm();
$this->orders->save($order);
});
}
}
This example is deliberately modest. It does not claim that every application needs a formal command bus or a rich domain model. Its value is simpler: the application has a named operation with a narrow responsibility. A controller can authenticate the request, map input, call the operation, and format a response. It should not be where business decisions accumulate.
Use layers to make change cheaper
Layering is not ceremony for its own sake. It is a way to prevent framework, transport, and storage details from spreading through code that contains business rules.
- HTTP or console adapters handle request parsing, authentication context, validation of input shape, and response formatting.
- Application services coordinate use cases: loading data, starting transactions, invoking domain behavior, and deciding what must happen next.
- Domain code expresses rules and state transitions that matter regardless of whether the caller is an API, queue worker, or CLI command.
- Infrastructure adapters implement persistence, cache access, external clients, file storage, and message delivery.
The boundaries do not need to be absolute on day one. A small service can begin with well-named application methods and repository interfaces where complexity justifies them. The warning sign is not a particular directory structure; it is when changing a payment rule requires editing SQL, HTTP response code, and a vendor SDK call in the same method.
Keep framework code at the edges
Laravel, Symfony, and similar frameworks solve real problems: routing, dependency injection, queues, validation, database access, and observability. Use them enthusiastically, but avoid making core rules depend directly on request objects or ORM models when those rules will be reused or become complex.
An ORM model is often an excellent persistence tool. It is less useful as the universal home for every workflow. If a model method starts sending HTTP requests, dispatching jobs, calculating policy, and changing multiple aggregates, move the orchestration into an application service. This also makes focused tests possible without booting an entire application.
Transactions protect data; workflows need more
A database transaction can atomically save an order and its payment state. It cannot atomically save those records and guarantee that an email provider, inventory platform, or webhook consumer receives a request. Trying to solve that with a remote API call inside a transaction creates awkward failure modes: the remote side may succeed while the database rolls back, or a slow dependency may hold locks longer than necessary.
A practical answer is the transactional outbox pattern. In the same database transaction that changes business state, store an event describing work to be performed after commit. A worker reads pending events and delivers them with retry logic.
$transaction->run(function () use ($order, $outbox): void {
$order->confirm();
$orders->save($order);
$outbox->add(new OutboxMessage(
type: 'order.confirmed',
payload: ['order_id' => (string) $order->id()]
));
});
The worker must assume delivery is at least once. A message can be sent successfully, then fail before it is marked complete. Consumers therefore need idempotency: processing the same event twice must not charge twice, create duplicate shipments, or send conflicting state changes. Stable event identifiers, unique constraints, and provider-supported idempotency keys are useful tools, but each integration needs its own explicit design.
Design APIs around intent and evolution
Resource endpoints remain valuable for straightforward reads and simple data maintenance. The problem is forcing every business operation through generic verbs. An intent-based endpoint such as POST /orders/{id}/confirm can be clearer than a patch payload whose meaning depends on a combination of fields.
Clarity also improves error handling. Distinguish malformed input from a valid request that violates a business rule, and distinguish both from temporary infrastructure failure. Clients do not need internal stack traces, but they do need stable error shapes and actionable codes. Logging should retain the correlation identifiers and context needed to investigate the failure without exposing secrets.
API evolution deserves the same care. Adding optional fields is usually easier than changing the meaning of existing fields. Avoid leaking database column names as your public contract when the domain vocabulary is more precise. A response should describe what clients need, not mirror whichever table happens to exist today.
Make performance a property of the design
Most PHP performance problems are not solved by clever syntax. They come from unnecessary work: N+1 queries, unbounded result sets, repeated remote calls, oversized payloads, and queue jobs that retry too aggressively.
Measure before optimizing, then make the cost visible. Paginate collection endpoints, select only required data, preload known relationships when appropriate, and set timeouts on outbound calls. Cache data only when you can state what invalidates it and what happens when the cache is unavailable. A fast but stale answer may be acceptable for a product catalog and unacceptable for an available balance.
Containers help make deployment repeatable, but Docker is not an architecture. Build immutable application images, provide configuration through the environment or a managed secret mechanism, and run database migrations as a deliberate deployment step. Do not assume a newly started container is ready merely because its process exists; readiness depends on the dependencies required to serve traffic safely.
Choose the next seam, not the grandest rewrite
Long-term vision does not require predicting every future feature. It means leaving the next important change a clean place to land. Extract a use case when its rules are growing. Introduce an outbox when reliable side effects matter. Separate an external client when a dependency becomes operationally significant. Keep a simple query simple when it truly is one.
The goal is not to ban CRUD. It is to put CRUD in its proper place: as a convenience for simple data operations, not as the mental model for the entire business. Systems become durable when their code reflects the decisions the business actually makes. That is the architecture worth maintaining.