Service Layer: Workflow Orchestration and Transaction Boundaries
Learn how to design a service layer that orchestrates multi-step business workflows, where to draw transaction boundaries around database writes, how to handle external calls like payment providers safely, and how to recognize and avoid a 'god service' that has taken on too many responsibilities.
A controller should stay thin — read the request, call something, format a response. The 'something' it calls is usually a service: a plain PHP class that knows how to carry out one meaningful piece of business work, such as placing an order. As an application grows, how you organize these services — and where you draw the line around a database transaction — determines whether the codebase stays testable or turns into a tangle nobody wants to touch.
What a service is for
In a layered PHP application, a service class sits between the controller (which handles HTTP concerns) and the lower-level collaborators (repositories for persistence, gateways for external APIs, mailers for notifications). Its job is to orchestrate: call the right collaborators in the right order, apply business rules, and decide what counts as success or failure for one specific use case.
A good service method reads like a numbered list of business steps, not like a maze of conditionals. Each step is usually a single method call to another object that actually does the work — the service itself mostly decides sequencing, checks the outcome of each step, and coordinates rollback or compensation when something fails partway through.
Where the transaction boundary belongs
A transaction boundary marks the start and end of a unit of work that must succeed or fail together at the database level — for example, decrementing stock and creating an order row must never happen as two independent commits, or you can end up with an order that references stock that was never actually reserved.
The service is almost always the right place to open and close that transaction, because the service is the only layer that knows the full scope of the business operation. Repositories should not begin transactions themselves — that would make it impossible for a service to combine two repository calls into one atomic unit — and controllers should not manage transactions either, because they know nothing about persistence at all.
The trickier design decision is what to do with work that cannot be rolled back by the database, such as charging a credit card through a third-party payment gateway. A transaction should be opened as late as possible and kept as short as possible, and it should never wrap a slow, non-transactional external call — if the database transaction is still open while you wait on a payment API over the network, you hold locks and connections for far longer than necessary, and if the gateway calls back into your own process you can deadlock. The safer pattern is: perform the external call first (or immediately after, outside the transaction), then open a short transaction purely for the local database writes that record its outcome.
From service to 'god service'
A god service starts innocently: OrderService gets an placeOrder() method, which is reasonable. Then someone needs to cancel an order, so cancelOrder() is added to the same class — still reasonable, it is order-related. Over months, refundOrder(), sendMarketingEmail(), generateInvoicePdf(), syncOrderToWarehouseSystem(), and recalculateLoyaltyPoints() all get bolted on because 'it touches orders somewhere,' until the class is 2,000 lines long, has a dozen constructor dependencies, and every unrelated change risks breaking something else that happens to live in the same file.
The fix is not a rule about line counts — it is asking whether each method represents the same use case or a different one. placeOrder and cancelOrder are genuinely different workflows that happen to share the word 'order'; they belong in PlaceOrderService and CancelOrderService (or similarly named, single-purpose classes), each with only the collaborators that specific workflow needs. A service class earns the right to exist by doing one job well, not by being the place where everything related to a noun accumulates.
Shape of an orchestrating service method
Steps that are hard to undo (charging a card) happen outside the database transaction so the transaction stays short. A failure after payment but before the local write must trigger a compensating action (here, releasing stock) rather than relying on rollback. Sending the confirmation email is deliberately outside the transaction and treated as best-effort — a failed email should not fail an already-placed order.
final class PlaceOrderService
{
public function __construct(
private readonly Connection $db,
private readonly StockReservation $stock,
private readonly PaymentGateway $payments,
private readonly OrderRepository $orders,
private readonly OrderConfirmationMailer $mailer,
) {}
public function place(PlaceOrderCommand $cmd): Order
{
// 1. Validate — cheap, no side effects yet.
$this->assertValid($cmd);
// 2. Reserve stock — a compensable side effect.
$reservation = $this->stock->reserve($cmd->items);
try {
// 3. Charge payment — external, non-transactional.
$charge = $this->payments->charge($cmd->paymentToken, $cmd->total);
} catch (PaymentDeclined $e) {
$this->stock->release($reservation);
throw $e;
}
// 4. Short local transaction for the durable write.
$order = $this->db->transactional(function () use ($cmd, $charge, $reservation) {
$order = $this->orders->create($cmd, $charge->id, $reservation->id);
$this->stock->commit($reservation);
return $order;
});
…Code Examples
This service orchestrates five distinct steps of the 'place an order' workflow. Validation happens first because it is free. Stock reservation and payment happen before any database transaction opens, because both involve state that is not covered by a SQL rollback (an in-memory/external reservation system and a third-party gateway). Only the final, purely-local writes (creating the order row, committing the stock reservation record) are wrapped in an actual database transaction, so that transaction stays short and never waits on network calls.
$this->db->beginTransaction(); ... $this->db->commit(); wraps only the local, rollback-able database writes — payment already happened outside it.
Common mistake: Wrapping the payment gateway call itself inside beginTransaction()/commit() — that holds database locks for as long as the network call to the payment provider takes, and a slow or hung gateway request can then block other transactions.
Every method here mentions 'order' somewhere, which is why it feels natural to keep adding to this one class — but placing an order, cancelling one, generating a PDF, syncing to a warehouse system, and sending a marketing newsletter are unrelated use cases with unrelated collaborators and unrelated reasons to change. A single change to how invoices are formatted now requires touching a class that also handles payments and marketing email, and every unit test for this class has to construct dependencies for features it isn't even testing.
Common mistake: Adding a new method to an existing service just because it operates on the same database table or business noun, instead of asking whether it represents the same workflow.
<?php
final class PlaceOrderCommand
{
public function __construct(
public readonly int $customerId,
public readonly array $items,
public readonly string $paymentToken,
public readonly float $total,
) {}
}
final class PlaceOrderService
{
public function __construct(
private readonly PDO $db,
private readonly StockReservation $stock,
private readonly PaymentGateway $payments,
private readonly OrderRepository $orders,
private readonly OrderConfirmationMailer $mailer,
) {}
public function place(PlaceOrderCommand $cmd): Order
{
if (empty($cmd->items)) {
throw new InvalidArgumentException('An order needs at least one item.');
}
$reservation = $this->stock->reserve($cmd->items);
try {
$charge = $this->payments->charge($cmd->paymentToken, $cmd->total);
} catch (PaymentDeclined $e) {
$this->stock->release($reservation);
throw $e;
}
$this->db->beginTransaction();
try {
$order = $this->orders->create($cmd, $charge->id, $reservation->id);
$this->stock->commit(
…<?php
// Do not write services like this.
final class OrderService
{
public function placeOrder(array $data): Order { /* ... */ }
public function cancelOrder(int $id): void { /* ... */ }
public function refundOrder(int $id): void { /* ... */ }
public function generateInvoicePdf(int $id): string { /* ... */ }
public function syncToWarehouseSystem(int $id): void { /* ... */ }
public function sendMarketingNewsletter(): void { /* unrelated bulk-email job */ }
public function recalculateLoyaltyPoints(int $customerId): void { /* unrelated to a single order */ }
public function exportOrdersToCsv(DateRange $range): string { /* reporting, not a workflow */ }
}Where the transaction boundary sits
The database transaction covers only the local, rollback-able writes at the end of the workflow — not the external payment call, and not the notification that follows it.
validate() → reserve stock → charge payment (external)
│
▼
┌── BEGIN TRANSACTION ──┐
│ create order row │
│ commit stock reserve │
└── COMMIT / ROLLBACK ───┘
│
▼
send confirmation email (best-effort)Important Rules
A service orchestrates a single, named business workflow — if you cannot name the workflow in one short phrase, it is doing too much.
Open the transaction as late as possible and close it as soon as possible; never hold it open across a slow external call.
Repositories should not manage transactions themselves — only the service knows the full scope of the unit of work.
A failed step after an irreversible external action (like a charge) needs an explicit compensating action, not just a rollback.
If a service's methods stop sharing the same workflow and the same core collaborators, split it into separate, single-purpose services.
Where this shows up
E-commerce checkout flows are the textbook case: reserving inventory, charging a card, writing the order, and emailing a receipt each have different failure modes and different rollback semantics. Frameworks like Laravel expose DB::transaction(callback) precisely so a service can wrap only the local writes in a closure, while payment SDKs (Stripe, PayPal) are always called outside that closure by convention.
beginTransaction(); $charge = $paymentGateway->charge(...); $orders->create(...); commit();
$charge = $paymentGateway->charge(...); beginTransaction(); $orders->create(...); commit();
The payment call is a slow, non-transactional network request. Keeping it inside the transaction holds database locks and a connection open for the entire round trip, hurting throughput and risking timeouts under load.
class OrderService { placeOrder(); cancelOrder(); sendNewsletter(); exportCsv(); recalcLoyalty(); }
Separate PlaceOrderService, CancelOrderService, NewsletterService, OrderExportService, and LoyaltyPointsService, each with only the dependencies its own workflow needs.
Grouping by a shared noun ('order') instead of a shared workflow produces a god service: every unrelated feature becomes coupled to the same file, the same constructor, and the same set of tests.
[Multiple Choice] Why should a call to an external payment gateway usually happen outside the database transaction, not inside it? A. PHP does not allow network calls inside a transaction B. The external call is slow and not covered by a SQL rollback, so wrapping it holds locks and connections open for no benefit C. Payment gateways require their own separate database D. Transactions can only contain SELECT statements
B. The external call is slow and not covered by a SQL rollback, so wrapping it holds locks and connections open for no benefit — A database transaction can only roll back local database writes. An external API call cannot be undone by ROLLBACK, and keeping the transaction open while waiting on that network call needlessly holds database locks and connections.
[True / False] A service class stays well-designed as long as every method operates on the same database table. A. True B. False
B. False — False. Sharing a table or business noun is not the same as sharing a workflow. Methods for unrelated use cases (placing an order vs sending a newsletter) should live in separate services even if they both 'touch orders.'
Sketch (in comments or pseudocode) a CancelOrderService::cancel(int $orderId) method for an already-paid order. Decide which steps need a database transaction and which do not, and how you would compensate if the refund call to the payment gateway fails after you've already marked the order cancelled locally.
// 1. Load order, assert it is cancellable. // 2. Call $payments->refund($order->chargeId) BEFORE any local write (external, non-transactional). // 3. If refund succeeds: open a short transaction, mark order cancelled, restock items, commit. // 4. If refund fails: do not touch local state; surface the error so it can be retried. // This ordering avoids a state where the order is marked cancelled locally but the customer was never refunded.
Think about ordering: should you mark the order cancelled before or after issuing the refund, and what happens if the process crashes between the two steps?