I've been writing Symfony professionally for 15 years. Over those years I started enough SaaS projects to notice an embarrassing pattern: the first two or three weeks were never about the product. They were email verification, password reset, 2FA, billing webhooks, tenant scoping, transactional emails, and a deploy checklist I kept half-remembering.
This year I built that foundation one final time — properly, with tests — and turned it into a product (ShipAnvil). This article is the technical write-up: the architecture decisions I'd defend in a code review, the ones that surprised me, and the parts that are harder than they look. Everything here stands on its own — you can take these patterns and build your own foundation with them.
1. The stack: boring on purpose, and zero Node.js
- Symfony 7.4 LTS + PHP 8.4/8.5, PostgreSQL.
- The LAST stack: AssetMapper, Stimulus, Turbo, Live Components.
-
Tailwind CSS v4 via
symfonycasts/tailwind-bundle.
The controversial choice is the absence of Node.js. AssetMapper serves ES modules with an importmap; the Tailwind bundle wraps a standalone binary. The practical consequence is bigger than it sounds: a new machine goes from git clone to a running app with PHP and PostgreSQL alone. No lockfile drift, no build pipeline to babysit in CI, one less runtime in production.
In 2024 I would have hedged on this. In 2026, after shipping real features with Live Components and Turbo, I wouldn't go back for a standard SaaS UI.
2. Billing: one interface, two providers, and a fake one
The single highest-leverage decision in the codebase. Stripe is the default answer, but Merchant-of-Record providers (Lemon Squeezy, Paddle) solve VAT for solo developers — a very real pain if you sell from the EU. I wanted both, swappable by env var:
#[AutoconfigureTag('billing.payment_provider')]
interface PaymentProviderInterface
{
public function name(): BillingProvider;
/** Creates a hosted checkout session for the plan and returns its URL. */
public function createCheckoutUrl(
Organization $organization,
Plan $plan,
User $user,
string $successUrl,
string $cancelUrl,
): string;
/** Provider-hosted customer portal (payment methods, invoices, cancellation…). */
public function createPortalUrl(Subscription $subscription, string $returnUrl): string;
public function cancelAtPeriodEnd(Subscription $subscription): void;
public function resume(Subscription $subscription): void;
// … webhook verification, see below
}
Three implementations: StripeProvider, LemonSqueezyProvider, and — the one I underestimated — FakeProvider. The fake one runs the entire subscription lifecycle (checkout, upgrade, dunning, cancellation) locally with no account, no API key, no network. It exists for tests, but it turned out to be the best onboarding feature: you can develop your whole billing UX before deciding which provider you'll even use.
Two non-obvious rules this interface encodes:
- Hosted checkout only. Embedding payment forms means PCI scope and provider-specific JS. Redirecting to Stripe Checkout / LS hosted checkout keeps the integration surface to "create a URL, handle a webhook".
- The organization id must travel with the checkout (metadata/custom data), because the webhook is the source of truth that attaches the subscription — the redirect back to your app is not guaranteed to happen.
3. Webhooks: the part that will page you at 3am
Every billing tutorial shows the happy path: event arrives, update the database. Production has other plans — duplicate deliveries, out-of-order events, retries during your deploy window. The pattern that survives all of that is verify → store → ACK → process async:
#[Route('/webhooks/{providerSlug}', name: 'billing_webhook', methods: ['POST'])]
public function __invoke(string $providerSlug, Request $request): JsonResponse
{
$provider = $this->providers->fromSlug($providerSlug);
// …
try {
$identity = $provider->verifyWebhook($request); // signature check on the RAW body
} catch (WebhookVerificationFailed $exception) {
return new JsonResponse(['error' => 'Invalid webhook signature or payload.'], 400);
}
$existing = $this->webhookEvents->findOneByProviderEventId($provider->name(), $identity->eventId);
if (null !== $existing) {
if ($existing->isProcessed()) {
return new JsonResponse(['status' => 'already processed'], 200);
}
// Stored but not (successfully) processed yet: requeue it.
$this->bus->dispatch(new ProcessWebhookEvent((string) $existing->getId()));
return new JsonResponse(['status' => 'requeued'], 202);
}
$event = new WebhookEvent($provider->name(), $identity->eventId, $identity->type, $identity->payload);
// persist + dispatch…
}
The details that matter:
- Verify the signature on the raw request body. Any framework normalization (decoding, re-encoding) breaks HMAC comparison. This is the most common webhook bug I've seen in the wild.
-
Idempotency lives in the database, as a unique constraint on
(provider, event_id)— not in application logic. Two PHP-FPM workers receiving the same delivery concurrently will both pass anifcheck; only one will survive the constraint. - ACK before processing. The webhook HTTP response should take milliseconds. The actual state change happens in a Messenger handler with retries. Symfony's Doctrine transport means this needs no Redis — a database table is a perfectly good queue at this scale.
- Test with replayed fixtures. The suite replays full billing lifecycles from signed webhook fixtures — including duplicates and out-of-order deliveries. Those are test cases, not incidents.
4. Multi-tenancy: a Doctrine filter, and the tests that earn the trust
For a team-based SaaS, the pragmatic model is single-database with an organization_id column. The risk is the day someone writes a query and forgets the WHERE. Doctrine's SQLFilter closes that hole globally:
final class OrganizationFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
{
if (!$targetEntity->getReflectionClass()->implementsInterface(OrganizationOwnedInterface::class)) {
return '';
}
return \sprintf('%s.organization_id = %s', $targetTableAlias, $this->getParameter('organization_id'));
}
}
A request subscriber enables the filter as soon as an authenticated user with a current organization is detected. Every query against an entity implementing OrganizationOwnedInterface gets scoped automatically — ORM queries, lazy-loads, joins.
Two honest caveats, because this pattern is often oversold:
- Console commands and Messenger workers run unfiltered. There is no "current request" there. You must scope explicitly in async code — and document that loudly, which is exactly the kind of footgun that belongs in a comment on the filter class itself.
- A filter you haven't tested is a hope, not a guarantee. The test suite creates two organizations and asserts that org A's queries can never see org B's rows — including through relations. If you take one thing from this article: write the isolation tests before you trust the filter.
Plan gating then becomes declarative — an attribute on the controller, and a listener that redirects lower plans to the billing page:
#[RequiresPlan('pro')]
public function advancedReports(): Response { … }
5. The AI module: HttpClient, not an SDK
Every SaaS I talk to is adding an AI feature, so the foundation ships a Claude integration. Deliberate choice: no vendor SDK — a thin client on Symfony's HttpClient with retries, timeouts and SSE streaming. An LLM API is two endpoints; an SDK is a dependency tree you don't control.
The pattern I use most is structured extraction via forced tool use. You give the model exactly one tool whose input schema is your target structure, and you force it to call that tool. The answer is the structure — never prose around it, never "Here's your JSON:":
$result = $this->aiClient->complete($request); // tool_choice forces the tool
$input = $result->toolInput
?? throw new AiRequestFailed(\sprintf('The model did not call the "%s" tool.', $tool->name));
foreach ($tool->requiredProperties() as $property) {
if (!\array_key_exists($property, $input)) {
throw new AiRequestFailed(\sprintf('The extraction is missing the required "%s" property.', $property));
}
}
return new ExtractionResult($input, $result);
Note the validation after the call: a model can return a tool call that omits required properties. Trust, but verify — then your callers get a typed array, every time.
Token usage is metered per organization in a plain table. That's the seed of usage-based billing later, and it answers "which customer is costing me money" on day one. The whole module hides behind an interface with a fake implementation, so dev and CI run without an API key — same philosophy as billing.
One more 2026 reality: developers buy code they'll modify with an agent next to them. The repo ships a CLAUDE.md describing the architecture, conventions and test commands. It costs an afternoon and it changes what Claude Code or Cursor can do with the codebase on day one.
6. Quality as a feature, not a slogan
Numbers from the codebase as of this week: 379 tests, 1,667 assertions, PHPStan at level max with zero errors, CS-Fixer, and a CI pipeline (lint + static analysis + tests against a real PostgreSQL service).
The non-obvious lesson: the test suite changed what I dared to build. Replayed webhook lifecycles made the double-provider billing tractable. Isolation tests made the Doctrine filter trustworthy. On a foundation meant to outlive its author's attention, tests aren't insurance — they're the enabling technology.
The other quality feature is rehearsed installation. make init checks your PHP version, installs dependencies, creates the database, builds the CSS and runs the full test suite. The deploy documentation is a step-by-step VPS recipe (PHP-FPM pool, Apache vhost, certbot, systemd worker for Messenger, OPcache with validate_timestamps=0 and what that implies for deploys) — because "works on my machine" is where most boilerplates quietly end.
What I'd tell you to steal
- An interface in front of your payment provider, with a fake implementation — even if you only ever use Stripe.
- Verify → store idempotently → ACK → process async for every webhook, with the unique constraint in the database.
- A Doctrine SQLFilter for tenancy, plus the isolation tests that make it trustworthy — and explicit scoping in CLI/workers.
- Forced tool use for any LLM feature that feeds your domain logic.
- A
CLAUDE.mdin any codebase another human (or agent) will inherit.
Full disclosure: this article comes out of building ShipAnvil, a commercial Symfony SaaS boilerplate I just launched (the site itself runs on it, and there's a public demo account on the landing page). The patterns above are reproducible from this write-up alone — but if you'd rather start from the tested implementation, that's what it's for. Criticism and questions welcome in the comments; I answer everything.
Top comments (0)