Objects Namespaces And Application Architecture
Microservices Tradeoffs for PHP Applications
Microservices are separate applications that communicate over a network and are usually deployed independently. They can help large organisations scale teams and systems, but they also add operational and programming complexity.
A microservice is not just a folder, namespace, or class. It has its own runtime boundary. A PHP billing service might expose HTTP endpoints or consume queue messages. A course platform might call it to create invoices, check payment status, or issue refunds.
The main tradeoff is this: microservices can reduce coupling between teams and deployments, but they replace in-process method calls with network calls, distributed data, independent failures, and harder debugging.
What Changes Compared With A Monolith
In a monolith, one PHP request can often call another class directly:
<?php
declare(strict_types=1);
final class BillingStatus
{
public function userHasPaid(int $userId): bool
{
return $userId === 10;
}
}
final class CourseAccess
{
public function __construct(
private BillingStatus $billingStatus,
) {
}
public function canOpenPaidCourse(int $userId): bool
{
return $this->billingStatus->userHasPaid($userId);
}
}
$access = new CourseAccess(new BillingStatus());
echo $access->canOpenPaidCourse(10) ? 'yes' : 'no';
echo PHP_EOL;
// Prints:
// yes
That call is fast, type-checkable, and happens inside one process.
In a microservice design, course access may need to call billing over HTTP or read an event-fed projection:
<?php
declare(strict_types=1);
interface BillingClient
{
public function userHasPaid(int $userId): bool;
}
final class CourseAccess
{
public function __construct(
private BillingClient $billing,
) {
}
public function canOpenPaidCourse(int $userId): bool
{
return $this->billing->userHasPaid($userId);
}
}
The code still depends on an interface, but the implementation now has to handle timeouts, authentication, response formats, retries, partial outages, and possibly stale data.
Network Calls Can Fail
A method call inside one PHP process usually either runs or throws immediately. A network call can fail in more ways:
- the other service is down
- the DNS lookup fails
- the connection times out
- the response is slow
- the response is malformed
- authentication fails
- the service returns a valid response with old data
Good PHP code at a service boundary should make those failures explicit.
<?php
declare(strict_types=1);
interface BillingClient
{
public function userHasPaid(int $userId): bool;
}
final class UnavailableBillingClient implements BillingClient
{
public function userHasPaid(int $userId): bool
{
throw new RuntimeException('Billing service is unavailable.');
}
}
final class CourseAccess
{
public function __construct(
private BillingClient $billing,
) {
}
public function accessMessageFor(int $userId): string
{
try {
return $this->billing->userHasPaid($userId)
? 'Course unlocked.'
: 'Payment required.';
} catch (RuntimeException) {
return 'Access cannot be checked right now.';
}
}
}
$access = new CourseAccess(new UnavailableBillingClient());
echo $access->accessMessageFor(10) . PHP_EOL;
// Prints:
// Access cannot be checked right now.
The right failure behaviour depends on the feature. Some calls can fail closed, some can fail open, some should retry, and some should queue work for later. The important point is that the decision is deliberate.
Data Consistency Changes
In a monolith with one database transaction, you might update an order and payment record together. With microservices, each service usually owns its own data. The order service should not write directly to the payment service database.
That means data may become eventually consistent. One service publishes an event, another service consumes it, and its local view updates slightly later.
<?php
declare(strict_types=1);
final readonly class PaymentReceived
{
public function __construct(
public int $userId,
public int $invoiceId,
public int $amountPence,
) {
}
}
final class PaidUserProjection
{
/** @var array<int, bool> */
private array $paidUsers = [];
public function apply(PaymentReceived $event): void
{
$this->paidUsers[$event->userId] = true;
}
public function userHasPaid(int $userId): bool
{
return $this->paidUsers[$userId] ?? false;
}
}
$projection = new PaidUserProjection();
echo $projection->userHasPaid(10) ? 'paid' : 'not paid';
echo PHP_EOL;
$projection->apply(new PaymentReceived(10, 500, 2999));
echo $projection->userHasPaid(10) ? 'paid' : 'not paid';
echo PHP_EOL;
// Prints:
// not paid
// paid
This approach can scale well, but it changes user experience and support workflows. A payment may have succeeded while another service has not processed the event yet.
Deployment And Ownership
Microservices can help when teams need independent ownership. A billing team can deploy billing changes without redeploying the course application. A search team can choose infrastructure that fits search. A high-traffic API can scale separately.
Those benefits require maturity:
- automated deployments
- service monitoring and alerting
- logs with correlation IDs
- API versioning
- contract testing
- retry and timeout policies
- clear data ownership
- local development tooling
- security between services
Without those, microservices often slow teams down.
PHP Is Fine For Services
PHP can be used for microservices. A PHP service can expose HTTP endpoints, process queue messages, run workers, and use frameworks such as Symfony, Laravel, Slim, Mezzio, or Spiral.
The important design question is not "can PHP do microservices?" It can. The question is whether the organisation needs the tradeoff.
PHP request/response applications are straightforward to operate as monoliths. Long-running workers, queues, and multiple services require more attention to memory usage, deployment, health checks, restarts, and observability.
Avoid Distributed Monoliths
A distributed monolith has the downsides of microservices without the independence.
Warning signs include:
- services must be deployed together every time
- one user action requires many synchronous service calls
- services share the same database tables
- teams cannot change one service without coordinating several others
- local development requires running many services for a small change
- failures cascade because timeouts and fallbacks are missing
If services are tightly coupled, separate deployment units may make the system harder rather than better.
When Microservices May Help
Microservices may be worth considering when:
- different parts of the system have clearly different scaling needs
- teams need independent ownership and deployment
- the domain boundaries are already well understood
- the organisation can operate multiple services reliably
- one part of the system has different security, data, or uptime requirements
They are usually a poor first move when the real problem is messy code inside one application. A modular monolith often fixes that problem with much less operational cost.
Service Size Is Not The Definition
A microservice is not defined by a small line count. It is defined by a cohesive capability, independent deployment, owned data, and an operational owner. A tiny endpoint that shares tables and must be released with five neighbors is not meaningfully independent.
Likewise, a service can contain several routes, workers, and internal modules while remaining one well-owned service. Splitting every entity into its own application creates excessive network communication and fragmented workflows.
Choose boundaries around business capability and ownership. Billing or Fulfillment may be reasonable. Separate InvoiceLineService, InvoiceTaxService, and InvoiceNumberService deployments are likely too granular unless they have genuinely independent lifecycles.
Contracts Need Deliberate Evolution
An internal PHP method call is checked against code loaded in the same release. A service contract connects independently deployed versions. Producers and consumers may run different versions for hours or weeks.
HTTP and message contracts therefore need:
- documented fields, status codes, and error shapes
- compatibility rules for adding and removing data
- versioning or migration periods for breaking changes
- schema validation at boundaries
- consumer-driven or provider contract tests where useful
- deprecation monitoring so old consumers can be found
Prefer tolerant additions. Adding an optional response field is usually easier than changing the meaning of an existing one. Event consumers should ignore fields they do not need, while producers should not remove fields until all consumers have migrated.
Do not expose a service's ORM entities as its contract. Create explicit request, response, and event schemas. Storage changes should not force every consumer to update.
Timeouts Are Mandatory
Every network call needs a timeout shorter than the caller's total request budget. Without one, blocked calls consume PHP workers and connections until upstream limits are reached.
Set separate connection and response/read timeouts when the client supports them. A checkout request with a two-second budget cannot safely make four sequential service calls that each permit two seconds.
Timeouts create an ambiguous outcome for commands. If a payment request times out, the caller may not know whether the service charged the card before the response was lost. Retrying blindly can charge twice.
Queries that only read data are often safer to retry than commands with side effects. Even then, retry only transient failures, use a limited attempt count, add exponential backoff and jitter, and stay inside the caller's deadline.
Idempotency Makes Retries Safer
An idempotent operation produces the same business outcome when the same request is processed more than once. Payment creation, webhook handling, and queue consumers should use stable operation or message identifiers.
<?php
declare(strict_types=1);
final class ProcessedMessages
{
/** @var array<string, true> */
private array $ids = [];
public function contains(string $messageId): bool
{
return isset($this->ids[$messageId]);
}
public function record(string $messageId): void
{
$this->ids[$messageId] = true;
}
}
final class GrantCourseAccess
{
public function __construct(private ProcessedMessages $processed)
{
}
public function handle(string $messageId, int $userId): string
{
if ($this->processed->contains($messageId)) {
return 'already processed';
}
// In production, grant access and record the message atomically.
$this->processed->record($messageId);
return 'access granted to ' . $userId;
}
}
$handler = new GrantCourseAccess(new ProcessedMessages());
echo $handler->handle('msg-1042', 10) . PHP_EOL;
echo $handler->handle('msg-1042', 10) . PHP_EOL;
// Prints:
// access granted to 10
// already processed
The in-memory example shows the decision, not production durability. A real consumer normally records the message ID in the same database transaction as its business update, often with a unique constraint that protects concurrent delivery.
Prevent Cascading Failure
Retries can multiply load on an unhealthy service. If 100 callers each retry three times, the struggling dependency receives 300 calls. Use backoff, retry budgets, and load shedding.
A circuit breaker can temporarily stop calls after repeated failures, allowing the dependency time to recover and letting callers fail quickly. A bulkhead separates resource pools so one dependency does not consume every worker or connection. Concurrency limits and queues provide backpressure when downstream capacity is lower than incoming demand.
These mechanisms need observable states and sensible defaults. A circuit breaker copied into every client with different thresholds can be harder to operate than a centralized, documented policy.
Fallback behavior must preserve business rules. Showing a cached product recommendation is reasonable. Approving a purchase because fraud screening is unavailable may not be.
Data Ownership Is Non-Negotiable
Each service should be the authority for its data. Other services use its API, consume its events, or maintain their own projections. They do not write directly to its tables.
Separate ownership does not always require a different database server, but permissions and migrations should preserve the boundary. Shared schemas and unrestricted credentials make accidental coupling easy.
Cross-service joins become read models, API composition, or analytics pipelines. These alternatives have freshness and failure tradeoffs that must be visible to product and support teams.
Distributed Workflows Need Compensation
A database transaction cannot atomically commit changes across independent service databases without introducing heavy coordination. Longer workflows commonly use a saga: a sequence of local transactions with messages between them and compensating actions when later steps fail.
For example, checkout may create an order, authorize payment, and reserve stock. If stock reservation fails after payment authorization, the workflow may void the authorization and mark the order failed. Compensation is a business operation, not a magical rollback; sending an email cannot be unsent, and a refund may itself fail.
Persist workflow state so processing can resume after a crash. Give every step an idempotency key. Define timeouts, manual-review states, and operator tools rather than assuming every saga completes automatically.
Publish Events Reliably
Writing business data and then publishing an event creates a gap: the process may crash after the database commit but before message publication. Publishing first creates the opposite problem if the database transaction later rolls back.
The transactional outbox pattern writes the business change and an outbox record in one local transaction. A relay publishes pending outbox records and marks them sent. Consumers must still handle duplicates because publication and acknowledgement are not one atomic operation.
Events should represent facts such as PaymentReceived, include stable identifiers and occurrence time, and avoid requiring consumers to call back immediately for every detail. Large event payloads duplicate data and increase privacy exposure; tiny events that force synchronous callbacks can recreate coupling.
Observability Must Cross Boundaries
One request may pass through an API gateway, PHP service, queue, worker, and database. Logs on one host are insufficient. Carry a correlation or trace identifier across HTTP headers and message metadata.
Useful signals include:
- request rate, error rate, and latency percentiles
- dependency and queue latency
- retry, timeout, and circuit-breaker counts
- queue depth and oldest-message age
- dead-letter messages
- deployment version and service name on every log
- business outcomes such as payment authorization rate
Distributed tracing can reveal call chains, but it does not replace structured logs and metrics. Instrument important boundaries and avoid recording secrets or unnecessary personal data.
Service-To-Service Security
Internal network location is not proof of trust. Authenticate service calls with managed credentials or workload identity, authorize the caller for the operation, encrypt traffic where the environment requires it, and rotate credentials without coordinated downtime.
Validate every request at the receiving boundary. Apply rate limits and payload limits. Sign webhooks and protect against replay with timestamps, nonces, or recorded message IDs. Keep secrets out of source control, logs, traces, and error responses.
Ownership includes patching dependencies, reviewing permissions, responding to incidents, and knowing which data crosses each service boundary.
Testing A Service Estate
Keep most business tests inside each service where they run quickly. Add integration tests for databases, brokers, and actual client serialization. Contract tests verify that provider and consumer assumptions agree without requiring the entire estate for every test.
A smaller number of end-to-end tests should cover critical journeys such as checkout and refund. End-to-end suites are slower and more fragile, so they cannot replace focused service tests.
Test failure behavior explicitly: timeout, malformed response, duplicate message, delayed event, unavailable dependency, partial workflow, and old contract version. Happy-path contract tests do not prove the system handles distributed operation.
Local development should allow a developer to run the service being changed with realistic substitutes for dependencies. Requiring every service and database to run for one unit of work is a sign of excessive coupling.
Extraction Should Follow A Stable Module
A practical path to microservices is to establish a modular monolith first. A module with clear APIs, owned tables, focused tests, and few synchronous callers is a plausible extraction candidate.
During extraction, route calls through the existing module interface, move data ownership deliberately, publish compatibility events, and observe both paths. Avoid a single release that moves code, data, contracts, and all callers at once.
Measure whether extraction achieved the stated goal: independent deployment frequency, failure isolation, team autonomy, or separate scaling. If every change still requires coordinated releases, the system paid the distribution cost without gaining independence.
What You Should Be Able To Do
After this lesson, you should be able to define a microservice by independent deployment and ownership rather than size. You should be able to design a versioned contract, assign timeout and retry budgets, require idempotency for repeated commands, and protect dependencies from cascading failure.
You should also understand owned data, eventual consistency, sagas, transactional outboxes, duplicate event handling, cross-service observability, service authentication, contract testing, and the evidence required before extracting a service from a modular monolith.
Practice
Practice: Handle A Billing Service Boundary
Create a small PHP example where a course application asks a billing service whether a user has paid.
Task
Build:
- a
BillingClientinterface - one implementation that returns a successful paid/unpaid result
- one implementation that throws an exception to simulate the service being unavailable
- a
CourseAccessclass that uses the client
The course access class should:
- unlock the course when billing says the user has paid
- require payment when billing says the user has not paid
- return a clear temporary message when billing is unavailable
Use strict types. Keep the expected output in the PHP code block as printed output or comments.
Check Your Work
Run cases for:
- paid user
- unpaid user
- billing unavailable
Afterward, explain why this boundary is more complex than calling another local class in a monolith.
Show solution
This solution treats billing as an unreliable boundary. CourseAccess receives a client interface and decides what message to return for paid, unpaid, and unavailable cases.
<?php
declare(strict_types=1);
interface BillingClient
{
public function userHasPaid(int $userId): bool;
}
final class FakeBillingClient implements BillingClient
{
/** @param array<int, bool> $paidUsers */
public function __construct(
private array $paidUsers,
) {
}
public function userHasPaid(int $userId): bool
{
return $this->paidUsers[$userId] ?? false;
}
}
final class FailingBillingClient implements BillingClient
{
public function userHasPaid(int $userId): bool
{
throw new RuntimeException('Billing service timed out.');
}
}
final class CourseAccess
{
public function __construct(
private BillingClient $billing,
) {
}
public function accessMessageFor(int $userId): string
{
try {
if ($this->billing->userHasPaid($userId)) {
return 'Course unlocked.';
}
return 'Payment required.';
} catch (RuntimeException) {
return 'We cannot check payment status right now.';
}
}
}
$workingAccess = new CourseAccess(new FakeBillingClient([
10 => true,
20 => false,
]));
$unavailableAccess = new CourseAccess(new FailingBillingClient());
echo $workingAccess->accessMessageFor(10) . PHP_EOL;
echo $workingAccess->accessMessageFor(20) . PHP_EOL;
echo $unavailableAccess->accessMessageFor(10) . PHP_EOL;
// Prints:
// Course unlocked.
// Payment required.
// We cannot check payment status right now.
A local monolith call usually fails immediately and is easier to trace in one process. A microservice call can time out, return bad data, fail authentication, or be temporarily unavailable. That is why service-boundary code needs explicit failure behaviour.
Practice: Build An Idempotent Consumer
Model a queue consumer that may receive the same PaymentReceived message more than once.
Task
Create:
- a
ProcessedMessageStoreinterface withcontains()andrecord()methods - an in-memory implementation
- a
CourseAccessStorethat records access by user ID - a
PaymentReceivedHandleraccepting a message ID and user ID
The handler must grant access once and return a duplicate result on repeated delivery. Print the first and second results.
Check Your Work
Explain why a production implementation should record the message ID and business update in one database transaction, and why a unique constraint is still needed when two workers process the same message concurrently.
Show solution
The handler checks durable message identity rather than assuming the broker delivers exactly once.
<?php
declare(strict_types=1);
interface ProcessedMessageStore
{
public function contains(string $id): bool;
public function record(string $id): void;
}
final class MemoryProcessedMessages implements ProcessedMessageStore
{
/** @var array<string, true> */
private array $ids = [];
public function contains(string $id): bool
{
return isset($this->ids[$id]);
}
public function record(string $id): void
{
$this->ids[$id] = true;
}
}
final class CourseAccessStore
{
/** @var array<int, true> */
private array $users = [];
public function grant(int $userId): void
{
$this->users[$userId] = true;
}
}
final class PaymentReceivedHandler
{
public function __construct(
private ProcessedMessageStore $processed,
private CourseAccessStore $access,
) {
}
public function handle(string $messageId, int $userId): string
{
if ($this->processed->contains($messageId)) {
return 'duplicate ignored';
}
$this->access->grant($userId);
$this->processed->record($messageId);
return 'access granted';
}
}
$handler = new PaymentReceivedHandler(
new MemoryProcessedMessages(),
new CourseAccessStore(),
);
echo $handler->handle('payment-9001', 42) . PHP_EOL;
echo $handler->handle('payment-9001', 42) . PHP_EOL;
// Prints:
// access granted
// duplicate ignored
In production, granting access and recording the ID belong in one transaction. A unique database constraint on the message ID resolves concurrent attempts that both pass an initial existence check.
Practice: Design Timeout And Retry Policy
A checkout request has a total server-side budget of two seconds and calls inventory, fraud screening, and payment services.
Task
Write a policy covering:
- connection and response timeouts for each dependency
- which reads or commands may be retried
- maximum attempts and backoff
- idempotency keys
- behavior when each dependency is unavailable
- metrics and logs required to diagnose failures
Reject any plan where each dependency receives the full two-second timeout or where payment commands are retried without an idempotency key.
Show solution
Reserve part of the two-second budget for application work and response delivery. One example is 250 ms for inventory, 400 ms for fraud, and 700 ms for payment, with small connection-timeout portions inside each budget. Calls should run concurrently only when their data dependencies permit it.
Retry an inventory availability read once only for a transient connection failure and only while time remains. Fraud screening should fail closed or move the order to manual review according to business policy. Do not silently approve because screening timed out.
Payment creation receives a stable checkout idempotency key stored by the payment service. It may be retried once after an ambiguous transport failure only when the same key guarantees one charge. A definite card decline is not retried.
Use bounded exponential backoff with jitter; never sleep beyond the remaining request deadline. If a dependency repeatedly fails, a circuit breaker can reject calls quickly and protect worker capacity.
Log the correlation ID, dependency, attempt number, timeout category, idempotency key hash or safe identifier, and final business outcome. Record latency percentiles, timeout count, retry count, circuit state, and checkout outcomes. Never log card details, secrets, or full sensitive payloads.