Razdvajanje ovisnosti: Izgradnja slabo povezanih PHP sustava
Most PHP applications do not become difficult because of one bad class. They become difficult because every class gradually learns too much about the rest of the system: the database driver, the mail provider, the framework request object, the payment SDK, and the exact way configuration is stored.
That kind of convenience feels productive at first. Later, a small change becomes a trail of edits across controllers, services, commands, and tests. Decoupling dependencies is the practice of stopping that spread. It gives each part of the application a clear job and a smaller set of assumptions about the world around it.
Coupling is not the enemy; uncontrolled coupling is
A system cannot have zero dependencies. A checkout service must eventually charge a payment provider, and a repository must eventually talk to a database. The goal is not to pretend those relationships do not exist. The goal is to place them at deliberate boundaries.
Loose coupling means a business rule depends on a capability, not on a particular implementation. For example, an order workflow may need something that can collect payment. It should not need to know whether that capability is provided by Stripe, a bank gateway, or a test double.
The practical benefit is not architectural purity. It is change containment. When infrastructure changes, fewer files need to change, and the behavior that matters can be tested without assembling the entire application.
Start with the direction of dependency
Business logic should sit near the center of the design. Framework code, database adapters, HTTP clients, queues, and vendor SDKs belong near the edges. Dependencies should generally point inward: the edge adapts itself to the needs of the business logic, rather than business logic adapting itself to every external detail.
A common warning sign is a domain-oriented class constructing its own collaborators:
final class InvoiceService
{
public function send(int $invoiceId): void
{
$invoice = new PDOInvoiceRepository();
$mailer = new VendorMailer();
// Load, render, and send the invoice.
}
}
This class is now responsible for invoice behavior and for choosing concrete infrastructure. It cannot be exercised without the database and mail integration, and changing either provider means editing the service.
Instead, express the dependencies in the constructor and depend on small interfaces owned by the application:
interface InvoiceRepository
{
public function getById(int $id): Invoice;
}
interface InvoiceSender
{
public function send(Invoice $invoice): void;
}
final class InvoiceService
{
public function __construct(
private InvoiceRepository $invoices,
private InvoiceSender $sender,
) {
}
public function send(int $invoiceId): void
{
$this->sender->send($this->invoices->getById($invoiceId));
}
}
A database-backed repository and a provider-specific mail adapter can implement those interfaces elsewhere. The service keeps its focus: coordinate the business operation.
Use dependency injection as a design habit
Dependency injection is often introduced as a container feature, but the container is secondary. The important habit is making dependencies visible. Constructor injection makes required collaborators explicit, creates valid objects from the start, and makes tests straightforward.
In a framework application, the container can compose the object graph at the edge of the system. That is useful, but avoid letting container lookups leak into business code. A call such as container->get() hides the class’s real dependencies and turns the container into a service locator.
For simple applications, composition can be equally clear without a container:
$repository = new PdoInvoiceRepository($pdo);
$sender = new MailProviderInvoiceSender($mailClient);
$service = new InvoiceService($repository, $sender);
Whether this wiring lives in a framework provider, a bootstrap file, or a command entry point, keep it near application startup. That is where concrete choices belong.
Design interfaces around your use case
Interfaces are valuable when they protect a meaningful boundary, not when every class receives one by default. An interface named DatabaseInterface with dozens of generic methods usually exposes too much infrastructure. A narrow interface such as InvoiceRepository describes what the application actually needs.
This distinction also prevents abstraction from becoming ceremony. Create an interface when at least one of these is true:
- The dependency is external infrastructure, such as storage, messaging, time, or an HTTP API.
- The business layer should remain independent of a framework or vendor package.
- You need a controlled test substitute for behavior that is slow, nondeterministic, or costly.
- More than one implementation is plausible now or likely as the system evolves.
Do not introduce an interface solely because a class exists. A small value object or a focused internal service can remain concrete until a real boundary emerges.
Keep framework and transport concerns at the edge
Controllers should translate HTTP concerns into application input and translate results back into HTTP responses. They should not contain a transaction workflow, query construction, or provider-specific error handling.
The same rule applies to command-line handlers, queue consumers, and scheduled jobs. Each is an adapter around the same application behavior. When the core workflow is independent of a request object or command object, it can be reused without copying logic.
Database transactions are a useful example of a boundary that needs careful placement. A use-case service may define the transactional unit of work, while a transaction runner is supplied as a dependency. This preserves the business-level meaning of atomicity without teaching every use case about PDO methods or a framework facade.
Do not leak vendor models through the application
Passing ORM entities, SDK response objects, or raw associative arrays everywhere makes the rest of the codebase inherit a vendor’s data model. Map external data into application-specific objects at the boundary. The mapping may feel repetitive, but it localizes the cost of future changes and makes invariants easier to see.
This does not mean every table requires an elaborate domain model. Pragmatism matters. A read-only reporting query may reasonably return a simple data structure. The key question is whether the representation is escaping into decisions that should outlive the current persistence or API choice.
Test behavior without rebuilding the world
Loose coupling improves tests because the important tests can run against small, deterministic collaborators. A fake repository or sender should implement the same contract, record useful interactions, and behave realistically enough for the scenario. It should not merely reproduce the production implementation’s internal details.
Still, unit tests are not enough. Adapters need integration tests against the actual database, HTTP protocol, or provider sandbox where available. The healthy split is simple: test business rules through narrow contracts, then test each infrastructure adapter where it meets the real technology.
Decouple in increments, not in one rewrite
Legacy PHP code rarely becomes loosely coupled through a dramatic redesign. Start at a painful seam: a service that creates an HTTP client directly, a controller that contains business rules, or a repository that returns framework-specific objects. Introduce one small contract, move the concrete implementation behind it, and wire it at the application boundary.
After each change, ask whether the new dependency makes the class easier to understand and test. If it only adds layers without clarifying responsibility, simplify it.
The lasting payoff
Loosely coupled PHP systems are not systems with the most interfaces. They are systems where changes have an obvious home. A payment provider changes in its adapter. A database query changes in its repository. A business rule changes in the use case that owns it.
That clarity is what keeps a codebase adaptable long after the first release. Build boundaries where change is likely, keep concrete technology at the edges, and let the center of the application speak in the language of the business. The result is not just easier testing. It is software that remains understandable when it matters most: during change.