DigiSpace

Symfony Architecture for Complex PHP Applications: Modules, Messenger and Integrations

Symfony Architecture for Complex PHP Applications: Modules, Messenger and Integrations

Symfony is useful when a PHP application has outgrown a collection of controllers and integrations. The framework gives a team a set of explicit boundaries—HTTP, validation, serialization, messaging and configuration—without forcing every business decision into one application shape. The value appears when those boundaries remain understandable after the product has been running for years.

The business result is a system that can absorb complexity without making every change a release risk: domain modules stay legible, slow work leaves the request path, and external integrations can be replaced behind a known contract.

Choose Symfony for the shape of the problem

A small CRUD application does not need an elaborate architecture. Symfony becomes a sensible choice when the system has several business domains, long-lived integrations, asynchronous workflows or a team that needs framework components outside a full-stack monolith. The decision should follow those constraints, not a preference for one brand of PHP framework.

The DigiSpace public application is Laravel-based, so this article is a technical guide rather than a claim about a Symfony production repository. The patterns are grounded in Symfony's official components and in the same engineering concerns visible in our PHP work: explicit request validation, thin controllers, testable services, queues, external APIs and deployments that can be rolled back.

Symfony request lifecycle with modular application code and asynchronous Messenger transport

Keep the HTTP layer boring

A Symfony controller should translate an HTTP request into an application command and translate the result back into an HTTP response. It should not decide how an invoice is calculated, how a provider is retried or how a database transaction is coordinated. Those decisions belong in application services or handlers that can be tested without constructing the whole kernel.

final class CreateOrderController
{
    public function __construct(
        private CreateOrderHandler $handler,
    ) {}

    public function __invoke(CreateOrderRequest $request): JsonResponse
    {
        $order = $this->handler->handle(
            new CreateOrderCommand(
                customerId: $request->customerId(),
                lines: $request->lines(),
            ),
        );

        return new JsonResponse(['id' => $order->id()], Response::HTTP_CREATED);
    }
}

The important part is the seam. Validation can reject malformed input at the edge; the handler can work with a command whose shape is known. That makes a later move from a synchronous controller to a message handler less disruptive.

Organize around domains, not folders

Symfony does not require a single directory layout. For a growing system, organize code around business capabilities such as Orders, Billing or Catalog. Each module can contain its application commands, domain rules, infrastructure adapters and HTTP entry points. The exact folders matter less than keeping dependencies pointed in one direction.

A domain module should not know whether its email was sent by Symfony Mailer, a queue worker or a test double. It should express the event or command. Infrastructure decides how that message travels. This separation is what makes a provider change a bounded task instead of a search through every controller.

Use Messenger when the request should finish sooner

Symfony Messenger supports both immediate handling and transports that process messages later. That lets a team begin with a synchronous message bus and move selected work to a queue when its latency or failure behaviour justifies it. The message is a small, serializable description of work; the handler owns the side effect.

final readonly class GenerateInvoice
{
    public function __construct(public string $invoiceId) {}
}

#[AsMessageHandler]
final class GenerateInvoiceHandler
{
    public function __invoke(GenerateInvoice $message): void
    {
        // Load the invoice, render the PDF, store it, notify the user.
    }
}

Queued work needs an operational policy. Configure retries for transient provider failures, a failure transport for messages that still cannot be handled, and an idempotency rule so retrying does not create duplicate charges or duplicate notifications. A queue is a delivery mechanism; it does not make a non-idempotent operation safe by itself.

Integrate external systems behind adapters

Symfony HttpClient gives integrations a consistent client abstraction, but it should not leak provider response formats through the application. Create a small adapter that maps the provider's request and response into your own value objects. Keep timeouts, authentication, retry policy and logging in the adapter or its transport configuration.

This is especially useful for payments, CRM synchronization and webhooks. The application can test a provider failure without calling the provider, while the adapter can have a focused contract test against a sandbox. When the provider changes its payload, one boundary changes first.

Make configuration and secrets explicit

Long-lived Symfony systems often fail at the edges of configuration: a worker has a different environment, a CLI command misses a secret, or a staging endpoint is used in production. Treat configuration as an input to the application. Validate required values at startup, keep secrets outside the repository, and make the worker's environment part of the deployment checklist.

Do not hide operational choices in static globals. A typed configuration object or injected parameter makes a dependency visible to the constructor and makes a test's assumptions readable.

Test the contracts that matter

Unit tests are useful for domain rules, but they do not replace HTTP and integration tests. A practical Symfony suite usually has three layers: fast tests for calculations and policies, application tests for command and handler behaviour, and a smaller set of HTTP tests for routing, validation and serialization. Integration tests should cover the boundaries where data can be lost—queues, webhooks, databases and external clients.

When modernizing an existing PHP application, characterization tests are a safe first step. Capture the current response and side effects before moving code. Once the contract is visible, replace one module or integration at a time and keep a reversible route or feature switch during the transition.

Plan deployment around workers and messages

Symfony deployments have a detail that ordinary HTTP deployments can miss: workers are long-lived. A new release may contain a changed message class or handler, while an old worker still has the previous code loaded. Restart workers as part of the release, use compatible message changes during a rolling deploy, and monitor the failure transport after activation.

Database migrations should be additive before they become destructive. Deploy a column or table first, make the application able to read both versions, backfill, then remove compatibility code in a later release. This keeps a rollback from depending on a database state that no longer exists.

What this approach buys the business

Modularity lowers the cost of parallel work and makes ownership clearer. Messenger keeps slow or failure-prone work away from the user request. Adapters make provider changes containable. Explicit configuration and worker restarts reduce the class of “works in one environment” incidents. Those benefits are practical; they do not depend on claiming that Symfony removes complexity.

When Laravel or components are the better fit

A team should not choose Symfony by default. Laravel may be faster for a product that benefits from its conventions and integrated application tooling. A smaller system may need only Symfony components—HttpClient, Messenger or Serializer—without adopting the full framework. The right answer is the smallest set of boundaries that keeps the business rules clear and the operational risks visible.

For the service overview, see Symfony Development Services. If you are choosing between Symfony, Laravel or selected components, start with the domains, integration failure modes and worker lifecycle your product actually has to support.

Laravel Multi-Tenant SaaS Architecture: Domain-Based Tenancy and PostgreSQL Isolation

Laravel Multi-Tenant SaaS Architecture: Domain-Based Tenancy and PostgreSQL Isolation

Multi-tenancy is easy to describe and difficult to keep safe. One application serves several organisations, each organisation expects its own users and data, and the platform owner still needs a central place for billing, support and administration. A domain lookup is only the first step. The real work is keeping tenant context correct across HTTP requests, authentication, queues, events and background services.

The business value is controlled growth: one product can serve many clinics while their operational data stays separated, onboarding follows a repeatable path, and the team can change the platform without creating a different codebase for every customer.

Start with the boundary, not the table column

VetSpace is a veterinary practice platform with a central owner portal and a tenant-specific clinic application. A clinic receives its own domain, staff work inside that clinic context, and pet owners use a shared central surface. That is a different problem from adding tenant_id to a few tables. The application has to decide which context a request belongs to before it reads the data behind that request.

The project uses Laravel 13, PostgreSQL and stancl/tenancy. Tenant domains are represented by a dedicated Domain model, while Tenant uses the package's database and domain concerns. The tenant model can therefore carry both the business identity and the connection details needed to initialise the correct context.

One application, two API contexts

VetSpace keeps central and tenant routes in the same Laravel application, but they do different jobs. The central API owns accounts, pets, orders, the clinic catalogue and subscription information. Tenant routes handle branches, services, doctors, rooms, appointments, visits and clinic reviews.

This separation is visible in the route files rather than hidden in a large controller. Tenant routes are loaded by TenancyServiceProvider, and tenant_api.php applies domain initialisation before the request reaches a tenant controller. The result is a straightforward rule: a central endpoint reads central data; a clinic endpoint runs inside the clinic's database context.

VetSpace multi-tenant architecture: central API and tenant API resolved by domain

Choosing isolation per plan

The platform does not treat every tenant as identical. The architecture supports a PostgreSQL schema or database for a tenant, with the plan deciding which level of isolation is appropriate. A shared database with separate schemas keeps smaller tenants operationally manageable. A dedicated database gives an enterprise tenant a stronger boundary and a simpler answer to questions about backup, restore and data residency.

This choice has a cost. More isolation means more connection management, migrations and operational work. Less isolation reduces that overhead but makes application-level scoping and restore procedures more important. The useful decision is the one that matches the product's support, compliance and recovery requirements; “database per tenant” is not automatically the right answer for every customer.

Compare the isolation options before choosing one

Common Laravel designs fall into three groups: shared tables with a tenant key, separate PostgreSQL schemas, and separate databases. Shared tables are simple to operate but every query boundary must be correct. Schemas add a database-enforced namespace while keeping one PostgreSQL installation. Separate databases improve isolation and tenant-specific recovery, but they increase connection, migration and monitoring work. The official Stancl tenancy documentation treats domain identification and database management as separate concerns for exactly this reason.

In VetSpace, the plan-aware choice is part of the product model. It lets the platform avoid paying enterprise isolation costs for every small clinic while keeping a stronger option available when the business case requires it. That is a product decision expressed through infrastructure, not a package default.

Middleware order is part of the security model

In a tenant API, authentication cannot be treated as an unrelated first step. The tenant has to be identified before the application knows which database should resolve a tenant-bound user or token. The project makes that dependency explicit by putting InitializeTenancyByDomain into the middleware priority from TenancyServiceProvider.

protected function makeTenancyMiddlewareHighestPriority(): void
{
    $tenancyMiddleware = [
        Middleware\PreventAccessFromCentralDomains::class,
        Middleware\InitializeTenancyByDomain::class,
        Middleware\InitializeTenancyBySubdomain::class,
    ];

    foreach (array_reverse($tenancyMiddleware) as $middleware) {
        $this->app[Kernel::class]
            ->prependToMiddlewarePriority($middleware);
    }
}

The route declaration explains why this matters: even if auth:sanctum appears before the tenancy middleware in the array, Laravel's priority list makes domain initialisation run first. This is the kind of detail that deserves a test and a comment because a future developer can otherwise “simplify” the route and change the data boundary by accident.

Provisioning a tenant is a lifecycle, not an insert

Creating a tenant starts a pipeline. The project creates the database, runs tenant migrations, seeds the tenant data, creates the main branch and replicates the owner. Deleting a tenant runs the corresponding database cleanup. Keeping these steps together makes onboarding repeatable and gives the platform one place to reason about partial failure.

JobPipeline::make([
    Jobs\CreateDatabase::class,
    Jobs\MigrateDatabase::class,
    Jobs\SeedDatabase::class,
    CreateMainBranch::class,
    ReplicateOwnerToTenant::class,
])->send(fn (Events\TenantCreated $event) => $event->tenant)
  ->shouldBeQueued(false);

The pipeline is deliberately synchronous at this boundary. Tenant creation should not report success while the clinic is still missing its tables or its first branch. Other work can be queued after the tenant is in a valid state.

Queued work must carry tenant context

Background jobs are where multi-tenancy becomes operationally real. RunAiAnalysis stores the tenant ID and analysis ID, looks up the tenant in the central context, initialises tenancy, performs the analysis, and ends the tenant context in a finally block. That last step matters when a long-running worker processes more than one tenant during its lifetime.

try {
    tenancy()->initialize($tenant);
    $analysis = AiAnalysis::find($this->analysisId);
    $service->analyze($analysis);
} finally {
    if (tenancy()->initialized) {
        tenancy()->end();
    }
}

The same principle appears in jobs that replicate users, create a clinic's main branch and synchronise VetCard data. A queue payload that contains only a record ID is not enough when that ID is meaningful only inside one tenant database.

Central records still need a second boundary

Not every record belongs exclusively to one clinic. Owners and pets live in a central space and can be linked to several clinics through pivot tables. VetSpace uses a ScopesToClinic trait to constrain those reads and writes to the authenticated user's clinic IDs, optionally including a branch. This protects against an insecure direct object reference even when the model itself is central.

That distinction is easy to miss: tenant database isolation protects tenant-local tables, while relationship scoping protects shared records. Both boundaries are required for a platform where an owner can visit more than one clinic.

What this architecture buys the business

The benefit is not the number of Laravel packages in composer.json. It is the ability to add a clinic without copying the application, to give different plans an appropriate isolation level, and to keep central billing and tenant operations connected without mixing their data. It also makes the operational risks visible: tenant migrations, queue context, domain routing, backup strategy and cross-clinic access all become explicit design work.

Where this approach is too expensive

A small internal tool with one organisation does not need domain-based tenancy, tenant database managers and a provisioning pipeline. A simple membership product may be better served by one database with carefully enforced row scoping. Multi-tenancy earns its complexity when several organisations share a product and the cost of data leakage, duplicated deployments or manual onboarding is higher than the infrastructure overhead.

For the service context behind this architecture, see Laravel Development Services.

When the boundary is real, design it early. Share the domains, data-isolation requirement and expected onboarding flow, and we can map the smallest architecture that keeps those boundaries dependable.

Modern PHP Application Modernization: A Safe Path from Legacy Code to PHP 8.3

Modern PHP Application Modernization: A Safe Path from Legacy Code to PHP 8.3

“Modernize the PHP application” sounds like a technology task. In a live business system it is a risk-management task: users still need the old workflows, integrations still expect the old payloads, and nobody can pause the business while every class is rewritten. The safest path is a sequence of observable changes that narrows the unknowns before it changes the architecture.

The business result is a codebase the team can change with less fear: behaviour becomes testable, dependencies become supportable, and each modernization step can be released or rolled back without a full rewrite.

Start with evidence, not a framework decision

Before choosing Laravel, Symfony or plain PHP, map what the application actually does. Record the PHP and extension versions, composer constraints, entry points, scheduled commands, queue consumers, database engines, external APIs and deployment assumptions. Search for dynamic includes, global state, direct SQL, filesystem writes and error handlers that can change control flow. These details define the migration surface more accurately than the framework name.

The PHP project behind DigiSpace runs on PHP 8.3 and Laravel 13 with MySQL 8. Its public site uses Blade, the admin panel uses Inertia and Vue, and the application integrates with Sanctum, Sentry, MinIO, Zoho CRM and reCAPTCHA. That list is useful because each integration is a seam to verify during an upgrade: an application can compile successfully and still lose a webhook, an uploaded asset or an authentication boundary.

Staged PHP modernization path from baseline through tests and typed seams to controlled release

Make the current behaviour visible

Legacy systems often lack tests around the behaviour that matters most. Add characterization tests before changing implementation. A request test can capture status codes, redirects and validation errors. An integration test can record the shape of a CRM payload or an uploaded file. A small database fixture can protect a query's important sorting and filtering rules.

These tests do not claim that the current behaviour is ideal. They tell you what the business is relying on today. Once that boundary is explicit, you can change the internals and decide deliberately when behaviour should change.

public function test_service_search_preserves_the_public_contract(): void
{
    Service::factory()->create([
        'title' => 'Laravel Development',
        'status' => 'active',
    ]);

    $response = $this->get('/en/services/search?search=Laravel');

    $response->assertOk()
        ->assertSee('Laravel Development');
}

The example is intentionally small. A useful characterization test protects a visible contract and stays understandable when the implementation moves.

Upgrade the runtime before redesigning everything

Runtime upgrades expose assumptions that have been hidden by old versions. Move through supported PHP versions in a controlled branch, run the test suite and static analysis at each step, and inspect deprecation notices instead of silencing them. PHP's supported-versions policy gives teams a reason to plan this work: active support and security support are finite, so an old runtime eventually becomes an operational constraint.

PHP 8.3 gives a codebase practical tools for making boundaries explicit, including typed class constants, readonly classes and stronger type declarations. Those features are useful where they describe an existing invariant. Adding them everywhere at once creates churn; adding them at service, DTO and configuration boundaries makes failures easier to locate.

Put typed seams around untyped code

A modernization does not require every legacy function to become a perfect domain model immediately. Introduce a small adapter around the old code, give the adapter a typed input and output, and let new code depend on that contract. The adapter becomes the place where nulls, legacy arrays and provider-specific errors are normalized.

final readonly class PublishResult
{
    public function __construct(
        public string $externalId,
        public bool $published,
    ) {}
}

interface ChannelPublisher
{
    public function publish(Post $post): PublishResult;
}

In DigiSpace, this style fits the existing separation between controllers, form requests, models and services. A request class such as ServiceSaveRequest validates the boundary; a service object can then work with values that have already been checked. The improvement is not the class count. It is knowing where invalid input is rejected.

Replace slices while the old path still works

For a large codebase, the strangler pattern is usually safer than a rewrite. Choose one business slice with a clear input and output: a search endpoint, an import, a billing adapter or an admin workflow. Route the new implementation behind the same contract, compare its behaviour, and remove the old path only after the new one has operated under real conditions.

This approach also protects deployments. A feature flag, route switch or reversible configuration lets the team return to the old implementation while a problem is investigated. The switch should have an owner and a removal date; a permanent dual path is another form of legacy.

Keep the data migration separate from the code migration

Changing tables and changing application behaviour in one release makes failures harder to diagnose. Prefer additive schema changes first: add a nullable column or a new table, deploy code that can read both representations, backfill in a controlled job, then make the new representation authoritative. Only later remove the old column or compatibility code.

The same rule applies to integrations. Write the new payload beside the old one, compare the result in logs or a safe sandbox, and switch the provider call after the contract is known. Sentry and structured application logs are more useful when each migration step records which path handled the request.

Deployment is part of the design

A modernized application still needs a boring release path. DigiSpace runs in Docker through Laravel Sail locally and deploys with a release-symlink workflow. That makes a release more than “copy the files”: dependencies, migrations, public assets, cache warming and the current symlink all need to agree. A rollback is only real if the previous release and its compatible database state are still available.

Before a cutover, exercise the important flows against the release candidate: login, public service pages, forms, uploads, queue work, CRM delivery and sitemap generation. These checks are cheap compared with discovering after release that a runtime upgrade changed a middleware order or an asset path.

What modernization buys the business

The value is cumulative. A supported runtime reduces security and hosting friction. Tests reduce the cost of changing a workflow. Typed seams make ownership clearer. Smaller releases make failures easier to roll back. None of these benefits requires claiming that the new architecture is perfect; they come from turning hidden assumptions into explicit boundaries.

When a rewrite is the honest answer

Incremental modernization is not always cheaper. If the data model is wrong, the runtime cannot be supported, the deployment process is unavailable, and no business behaviour can be isolated, a replacement may be justified. Even then, migrate by capability and keep the old system as a reference for behaviour. A rewrite is a delivery strategy, not permission to discard what users depend on.

For the broader implementation context, see PHP Development Services. If you are deciding between an upgrade, an incremental replacement or a new Laravel/Symfony application, start with the runtime, integrations and business flows that cannot fail.