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 example
<?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 example
<?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 example
<?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 example
<?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.

What You Should Be Able To Do

After this lesson, you should be able to explain that microservices are a deployment and ownership tradeoff, not simply a code organisation style. You should be able to identify service-boundary concerns such as timeouts, retries, authentication, data ownership, eventual consistency, and observability.

For junior work, the practical skill is to treat service calls as unreliable boundaries. Do not write PHP code that assumes another service is just a local method with a slower name.

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 BillingClient interface
  • one implementation that returns a successful paid/unpaid result
  • one implementation that throws an exception to simulate the service being unavailable
  • a CourseAccess class 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 example
<?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.