Blog
Designing boundaries and dependencies from day one in Symfony and React
Give Symfony and React clear responsibilities, choose patterns that solve real problems and protect useful boundaries with tests, without unnecessary layers.
Start with the change that should stay local
Consider a fictional application for managing repairs at a community centre. A staff member reports a damaged fitting, a technician records the work, and a coordinator closes the ticket. Some categories require a second person to review the repair. After closure, an external noticeboard service can update the room announcement.
The first version needs neither microservices nor a catalogue of design patterns. It does need an answer to three questions: who decides whether closure is allowed, who records it, and who understands the noticeboard provider? If changing that provider later touches the controller, the closure rule and React, the dependency already reaches too far.
We will build around this one operation. The examples describe a fictional contract, not an existing GiSoft deployment. PHP assumes 8.2 or later; the framework context is Symfony 7.4 and React 19. Omitted project classes are identified beside the snippets.
Give each responsibility an owner
React owns the interaction: show the ticket, submit a closure request and explain the result. Symfony authenticates the caller, checks permissions and enforces the transition. The application operation coordinates those decisions with persistence. An adapter knows how the external provider represents an announcement.
This map separates calls from implementation relationships; it is not a mandatory sequence of framework listeners:
React repairs -- JSON --> RepairController --> CloseRepair
CloseRepair uses ClosurePolicy and RepairTickets
DoctrineRepairTickets implements RepairTickets
Notice handler uses RoomBulletin
RemoteRoomBulletin implements RoomBulletinThe application owns the capabilities it needs. Infrastructure implements them; Symfony's container selects the implementations. Runtime calls still reach Doctrine and the provider, but their APIs need not become dependencies of the policy. Complete framework independence would add work of its own. Isolate the parts where change or testing makes that work worthwhile.
From an HTTP action to an application operation
An early controller that reads a ticket and updates a simple description is understandable. Trouble starts when it also decides whether a second review is required, closes the record, calls the noticeboard SDK and serialises the entity. A later CLI import either repeats the rule or calls code shaped around an HTTP request.
The smaller correction is to extract CloseRepair. Keep routing, request decoding, input validation and HTTP response mapping in the controller. Derive the actor from the authenticated context, never from an unchecked user ID in JSON. Pass the ticket ID and validated revision to the operation. The controller can still contain several useful lines.
This excerpt defines project capabilities, not Symfony or Doctrine APIs. RepairTicket, Actor, ClosurePermission and the exceptions are omitted project types. ClosurePermission checks both the actor's permission and access to this ticket's location; requireAwaitingClosure() rejects an invalid current state.
interface RepairTickets
{
public function get(string $id): RepairTicket;
public function closeIfCurrent(
string $id, int $revision, string $actorId,
): bool;
}
final readonly class CloseRepair
{
public function __construct(
private RepairTickets $tickets,
private ClosurePolicy $policy,
private ClosurePermission $permission,
) {}
public function __invoke(Actor $actor, string $id, int $revision): void
{
$ticket = $this->tickets->get($id);
$this->permission->requireClosure($actor, $ticket);
$ticket->requireAwaitingClosure();
if (($reason = $this->policy->rejection($ticket->closureFacts())) !== null) {
throw new ClosureRejected($reason);
}
if (!$this->tickets->closeIfCurrent($id, $revision, $actor->id)) {
throw new StaleRepair();
}
}
}get() must fail explicitly for a missing ticket. closeIfCurrent() has a substantial contract: atomically compare the expected revision and eligible state, then write the closure, new revision and actor's audit record. Every change to facts used by the decision must participate in this revision scheme. Otherwise the earlier check can become stale before the write. The adapter may use optimistic locking or an equivalent conditional write; the interface alone proves none of this.
A real implementation must also choose transaction boundaries and map expected failures to safe HTTP responses. The example deliberately leaves the database implementation out. The permission service and policy are concrete classes: they do not each need an interface merely because they are dependencies.
Put the closure rule where both entry points can use it
For this application, a work note is mandatory. A category requiring review also needs a reviewer other than the person who performed the repair. The following policy makes that decision without HTTP, Doctrine or a clock:
final readonly class ClosureFacts
{
public function __construct(
public string $workNote,
public bool $needsReview,
public string $performedBy,
public ?string $reviewedBy,
) {}
}
final class ClosurePolicy
{
public function rejection(ClosureFacts $facts): ?string
{
if (trim($facts->workNote) === '') {
return 'work_note_missing';
}
if ($facts->needsReview && (
trim($facts->reviewedBy ?? '') === ''
|| $facts->reviewedBy === $facts->performedBy
)) {
return 'independent_review_required';
}
return null;
}
}These facts come from trusted stored records and category rules. The browser cannot declare its own review complete. Non-empty staff identifiers and the validity of recorded reviews are model invariants. Changing the reviewed work invalidates that review and advances the revision. Permission to close a ticket remains a separate check.
A policy earns its place here because the decision is shared with a CLI workflow and has several independent cases. An entity method or a small application method would also be reasonable. Creating a Specification for every if would add navigation without clarifying the rule.
With Pest 3, the pure PHP decision can be tested without booting Symfony. The classes above must be autoloaded by the test project; no fixture builder or hidden helper is assumed.
it('requires a work note and any required independent review', function (
string $note, bool $review, ?string $reviewer, ?string $expected,
): void {
$facts = new ClosureFacts($note, $review, 'worker-7', $reviewer);
expect((new ClosurePolicy())->rejection($facts))->toBe($expected);
})->with([
['', false, null, 'work_note_missing'],
['hinge adjusted', true, null, 'independent_review_required'],
['hinge adjusted', true, 'worker-7', 'independent_review_required'],
['hinge adjusted', true, 'worker-9', null],
['hinge adjusted', false, null, null],
]);The successful cases matter as much as the refusals. This test says nothing about database locking or HTTP permissions.
Keep persistence and public data separate where it matters
A Repository can express operations such as loading and closing a ticket. An interface is useful here because the application needs a defined persistence capability with a concurrency contract. It is not a requirement for every entity or every lookup. Injecting EntityManager throughout the application, or returning a QueryBuilder for callers to finish, makes those callers responsible for persistence decisions.
For a room's repair list, a Query object can hold a complex, reusable query; a read repository can group related reads. A projection/read model selects bounded fields such as ticket ID, room label and status. It need not load every note, attachment and staff record. A simple lookup does not need all three abstractions. Query count and response-size limits are useful regression tests only where those are real risks.
The models have different jobs. A persistence entity describes stored state; it may also contain business behaviour. A request DTO represents untrusted input. A response DTO or explicit array defines public output. A read model serves a query, while a React view model serves a screen. A value object is worthwhile when a concept such as a room code has meaningful invariants. One feature does not need seven copies of the same fields.
Doctrine entities may remain inside the application when that is a deliberate trade-off. Do not expose their entire serialised graph as the API merely because the serializer can traverse it. A short mapping function is often sufficient; a mapper class is useful only when the transformation deserves one.
Contain the provider's vocabulary
Boundary and integration patterns solve different sizes of problem. A port, usually a project-owned interface, names a required capability. An Adapter converts that capability into a provider call and translates the result. Here, ClosureNotice is an omitted project DTO containing only the announcement data the provider needs:
interface RoomBulletin
{
public function recordClosure(
ClosureNotice $notice,
string $operationId,
): void;
}A Facade helps if publishing a notice requires several low-level provider operations. It adds little when it merely renames a single method. An anti-corruption layer (ACL) is justified if the provider's concepts conflict with ours: its “closed” announcement might mean withdrawn, not a completed repair. A small field conversion rarely needs a whole layer.
A DTO/View Model limits what crosses a transport or presentation boundary. Avoid copying a provider response into a project DTO with identical fields and calling the dependency isolated: vendor-specific meanings can still leak through. For a tiny, already isolated dependency, one adapter function may be enough. The operationId parameter expresses a deduplication requirement; it does not make an arbitrary provider idempotent.
Let React handle interaction through a feature contract
The example API uses POST /api/repairs/{id}/close with a revision. Its documented contract selects 204 for success, 409 for a revision/state conflict and 422 for a closure-rule rejection. These are design choices. Authentication failures and permission denials have their own agreed responses, conventionally 401 and 403 for this JSON API, with tests for anonymous, denied and authorised callers.
A feature-owned closeRepair function handles the HTTP details: the intended session or bearer credential, CSRF protection appropriate to that credential, response validation and mapping of safe public error codes. It returns the small result below. It does not expose PHP exception names or blindly display arbitrary server text. TypeScript declarations cannot validate external JSON at runtime.
The component owns pending, refused and successful states. This is the component file, with the network adapter omitted; the function type is our contract, not a framework helper.
import { useState } from 'react';
export type CloseResult =
| { kind: 'closed' }
| { kind: 'blocked'; message: string };
export type CloseRepair = (id: string, revision: number) => Promise<CloseResult>;
type Props = { id: string; revision: number; closeRepair: CloseRepair };
export function CloseRepairButton({ id, revision, closeRepair }: Props) {
const [phase, setPhase] = useState<'idle' | 'pending' | 'closed'>('idle');
const [notice, setNotice] = useState('');
async function submit() {
if (phase !== 'idle') return;
setPhase('pending');
setNotice('');
try {
const result = await closeRepair(id, revision);
setPhase(result.kind === 'closed' ? 'closed' : 'idle');
if (result.kind === 'blocked') setNotice(result.message);
} catch {
setPhase('idle');
setNotice('Could not confirm closure. Check the ticket before retrying.');
}
}
return (
<>
<button type="button" disabled={phase !== 'idle'} onClick={submit}>
{phase === 'closed' ? 'Repair closed' : 'Close repair'}
</button>
{notice && <p role="alert">{notice}</p>}
</>
);
}In Next.js, import this component within a client subtree. A parent with 'use client' can supply the callback from the feature's browser adapter. Do not pass an ordinary callback from a Server Component across that boundary. Server-side requests also need an explicit credential design; they do not inherit the browser's credentials automatically.
A disabled button improves interaction but cannot enforce backend permissions, concurrency or idempotency. React can flag an empty work note immediately; Symfony must still reject it. After an uncertain network result, inspect or refresh the ticket before resubmitting. An automatic retry of a mutation needs its own safety contract.
Keep modules small and their dependencies visible
One possible backend layout is below. These folders describe the fictional feature, not a proposed reorganisation of this repository:
src/Repairs/
Application/ CloseRepair, RepairTickets
Domain/ ClosurePolicy, RepairTicket
Infrastructure/ DoctrineRepairTickets, RemoteRoomBulletin
UI/ RepairController
features/repairs/
api/ closeRepair
model/ CloseResult
ui/ CloseRepairButton
shared/ui/ ButtonReact does not need to copy the backend's layers. Keep endpoint mapping with the feature; share an HTTP primitive only when several features actually need the same transport behaviour. A global ApiService owning every endpoint becomes another oversized module.
The rooms feature may expose a small application contract for location access. Repairs should not import its private repository or alter its entity behind its back. A direct call between application services can be perfectly adequate; an event is not compulsory. Circular dependencies often mean ownership needs clarification, not another interface.
Constructor arguments make dependencies visible. Reaching into the service container hides them. Shared, Common, Utils and Helpers also need ownership: move stable cross-feature concepts there deliberately. Two similar functions are sometimes cheaper than coupling two features to an abstraction whose meaning has not settled.
Decision and creation patterns: use the smallest useful tool
Policy, Strategy, Specification and state transitions address different questions. Our Policy decides whether closure is allowed. A Strategy could choose between genuinely interchangeable technician-assignment algorithms. With one algorithm, a normal service is enough. A Specification gives a reusable, composable condition a name; a local check rarely needs that machinery.
Keep simple state transitions explicit in the entity or operation. A state machine becomes useful when many states, transitions and guards need a common model. Three obvious transitions do not justify it on their own. The backend owns these decisions even if React presents a convenient preview.
Named constructor, Factory and Builder concern creation. RepairTicket::reported(...) can make an initial state and its invariants clear. A Factory helps when creation needs reference data or a meaningful choice between implementations. Ordinary DTO construction needs neither a factory nor a service. A Builder is particularly useful for test scenarios with optional reviews and notes, provided it exposes the state relevant to the test rather than hiding it behind generous defaults.
Each abstraction adds a concept, wiring and files to navigate. Choose it when that cost buys a clearer decision or construction rule.
Coordinate the operation without building a command framework
An application service/use case holds the sequence already shown: load, check access, evaluate the rule, persist. It need not know Request, React or a vendor response type. A simple CRUD operation can stay much smaller.
A Command can represent an intention to close a ticket; a Query can represent a request for a room's open repairs. Separate reads from writes where it helps understanding, without requiring an object or bus for every method call. Command/query separation does not require full CQRS, independently stored read models or asynchronous updates.
CQRS becomes worth considering when read and write needs diverge enough to pay for separate models and, where applicable, synchronisation. A message handler is useful for work intentionally executed through messaging. It is not a mandatory wrapper around every application service.
Keep technical wrappers from hiding business decisions
A Decorator can measure the noticeboard adapter or cache a read behind the same contract. A Middleware can apply a consistent rule to a transport or message path. Both are useful for repeated logging, metrics and tracing; introducing them around one straightforward call may obscure more than it helps.
An event subscriber/listener suits a technical reaction with understood ordering. Hiding the permission check or the decision to close a ticket in a Doctrine listener makes the operation difficult to follow. Keep critical synchronous decisions visible. A Pipeline can organise genuinely ordered processing stages; a short method does not need a stage registry.
Caching and retry also change behaviour. A cache wrapper needs keys and invalidation consistent with permissions and freshness. A retry wrapper must know which failures and operations are safe to repeat. Calling either “cross-cutting” does not remove that responsibility.
Decide what closure means before adding a queue
The ticket is closed when the authorised state change is committed. Updating the noticeboard is secondary in this example. Symfony Messenger may handle that later; moving the closure permission check to a delayed handler would change the contract. Queues relocate work and introduce delivery concerns rather than automatically improving the design.
If losing an announcement is unacceptable, an Outbox can store the intent in the same database transaction as the closure. A worker subsequently delivers it. This still permits repeated delivery. An idempotency key identifies the same logical notification across attempts; preventing duplicates needs atomic local coordination or suitable provider support, not just an operationId field.
A Retry policy should bound attempts for transient failures, not retry permanent permission errors. A Circuit breaker can temporarily stop calls to a persistently failing provider. A fallback must be an accepted outcome, such as showing “announcement pending”, rather than pretending delivery succeeded. These mechanisms have storage and operational costs. Introduce them according to delivery requirements, not as a starter template.
A pattern is a choice with a cost
Use this compact selection aid when reviewing a proposed abstraction. The simpler option remains valid when it protects the same responsibility.
Problem Candidate Simpler option
Provider model Port + Adapter / ACL Isolated mapping
Shared decision Policy / Specification Local method
Several algorithms Strategy One service
Complex list Query + projection Simple lookup
Repeated telemetry Decorator / Middleware Direct instrumentation
Creation rules Factory ConstructorThe table is not a shopping list. For the first closure feature, a concrete policy, one application service, a persistence capability and a feature-owned API function may be sufficient. Add the provider adapter when that integration arrives. Do not prebuild interfaces for every class, factories for every DTO, domain events for every update or Messenger/CQRS infrastructure for a synchronous form.
Test the boundary that can actually fail
Static checks give early feedback: PHPStan and TypeScript detect type and nullability problems. Import rules or architecture tests can enforce accepted dependency directions, such as keeping vendor SDKs out of application code or server-only imports out of client modules. They need explicit rules and supporting tooling; the tools do not infer the architecture or prove business behaviour.
The policy test already covers decisions cheaply. An integration test should exercise DoctrineRepairTickets against an isolated test database: real filtering, revision conflict and rollback where required. Mocking QueryBuilder would not prove those behaviours. Adapter tests use controlled provider responses to verify translation and failure handling, not the live provider during ordinary CI.
A functional Symfony API test covers routing, decoding, permissions, validation and the public result, with selected side effects. Test a coordinator who succeeds as well as callers who are refused. The GiSoft article “How to test Symfony APIs with Pest” develops the HTTP test cases in detail.
For React, test observable interaction. This Vitest/React Testing Library example assumes a DOM environment and @testing-library/jest-dom/vitest in test setup. It uses the component above and a deliberately unresolved promise, with no timer or network request:
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, it, vi } from 'vitest';
import { CloseRepairButton, type CloseResult } from './CloseRepairButton';
it('keeps closure disabled while the request is pending', async () => {
const user = userEvent.setup();
let finish!: (result: CloseResult) => void;
const response = new Promise<CloseResult>((resolve) => { finish = resolve; });
const closeRepair = vi.fn().mockReturnValue(response);
render(<CloseRepairButton id="repair-42" revision={8} closeRepair={closeRepair} />);
const button = screen.getByRole('button', { name: 'Close repair' });
await user.click(button);
expect(button).toBeDisabled();
expect(closeRepair).toHaveBeenCalledWith('repair-42', 8);
await act(async () => { finish({ kind: 'closed' }); });
expect(screen.getByRole('button', { name: 'Repair closed' })).toBeDisabled();
});Add a refusal case that preserves the user's work and allows correction. A contract/adapter test checks status mapping, JSON shape, nullable values, dates and safe error codes against the actual backend contract. A cast to a TypeScript type is not such a test.
One browser journey can cover reporting, review and closure across the application. Keep the policy's edge cases in unit tests. Large snapshots, shared fixtures, wall-clock dependencies and hidden helpers increase maintenance without necessarily protecting another risk. Use deterministic records and isolated state; run fast checks early, then the relevant integration, API and selected browser checks. Exact CI ordering depends on the project.
Boundary / risk Design tool Reliable proof
Closure decision Policy Unit test
Revision and storage Persistence contract DB integration
Provider translation Port + Adapter Adapter contract
HTTP permission Security boundary Functional API
Pending / error UI Feature component Component test
Complete workflow Several boundaries Selected browser testStart with one complete feature
Implement reporting and closure through the real HTTP and persistence boundaries before creating a large directory tree. Record who owns the rule, what the API promises, what a concurrent update means and whether notification failure changes the outcome. Review these decisions when a second use case creates a real need for reuse.
Keep detailed Doctrine optimisation, firewall diagnosis, Messenger redelivery and API test catalogues in their dedicated articles. Here the useful design check is simpler: changing the noticeboard provider should stay in its adapter; changing the closure rule should have an obvious owner and a focused test. Add structure when it makes one of those changes safer.
Technical references
Version-sensitive details were checked against the following documentation; the repair scenario and examples are specific to this article.
- Doctrine ORM 3.6, transactions and concurrency: https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/transactions-and-concurrency.html
- React, client module boundaries: https://react.dev/reference/rsc/use-client
- Symfony 7.4, Messenger delivery and retries: https://symfony.com/doc/7.4/messenger.html
