<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Eric Mollenthiel</title>
    <description>The latest articles on DEV Community by Eric Mollenthiel (@mollenthiel).</description>
    <link>https://dev.to/mollenthiel</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4038043%2F5bc4ed1d-d0e4-4591-860d-482f6002bb0f.webp</url>
      <title>DEV Community: Eric Mollenthiel</title>
      <link>https://dev.to/mollenthiel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mollenthiel"/>
    <language>en</language>
    <item>
      <title>Building a production-grade SaaS foundation in Symfony in 2026 — lessons from building ShipAnvil</title>
      <dc:creator>Eric Mollenthiel</dc:creator>
      <pubDate>Mon, 20 Jul 2026 12:51:17 +0000</pubDate>
      <link>https://dev.to/mollenthiel/building-a-production-grade-saas-foundation-in-symfony-in-2026-lessons-from-building-shipanvil-b8a</link>
      <guid>https://dev.to/mollenthiel/building-a-production-grade-saas-foundation-in-symfony-in-2026-lessons-from-building-shipanvil-b8a</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This year I built that foundation one final time — properly, with tests — and turned it into a product (&lt;a href="https://shipanvil.com" rel="noopener noreferrer"&gt;ShipAnvil&lt;/a&gt;). 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The stack: boring on purpose, and zero Node.js
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Symfony 7.4 LTS&lt;/strong&gt; + PHP 8.4/8.5, PostgreSQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The LAST stack&lt;/strong&gt;: AssetMapper, Stimulus, Turbo, Live Components.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind CSS v4&lt;/strong&gt; via &lt;code&gt;symfonycasts/tailwind-bundle&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;code&gt;git clone&lt;/code&gt; to a running app with PHP and PostgreSQL alone. No lockfile drift, no build pipeline to babysit in CI, one less runtime in production.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Billing: one interface, two providers, and a fake one
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[AutoconfigureTag('billing.payment_provider')]&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PaymentProviderInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;BillingProvider&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/** Creates a hosted checkout session for the plan and returns its URL. */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;createCheckoutUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;Organization&lt;/span&gt; &lt;span class="nv"&gt;$organization&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;Plan&lt;/span&gt; &lt;span class="nv"&gt;$plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$successUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$cancelUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/** Provider-hosted customer portal (payment methods, invoices, cancellation…). */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;createPortalUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Subscription&lt;/span&gt; &lt;span class="nv"&gt;$subscription&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$returnUrl&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;cancelAtPeriodEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Subscription&lt;/span&gt; &lt;span class="nv"&gt;$subscription&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Subscription&lt;/span&gt; &lt;span class="nv"&gt;$subscription&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// … webhook verification, see below&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three implementations: &lt;code&gt;StripeProvider&lt;/code&gt;, &lt;code&gt;LemonSqueezyProvider&lt;/code&gt;, and — the one I underestimated — &lt;code&gt;FakeProvider&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;Two non-obvious rules this interface encodes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hosted checkout only.&lt;/strong&gt; 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".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The organization id must travel with the checkout&lt;/strong&gt; (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.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  3. Webhooks: the part that will page you at 3am
&lt;/h2&gt;

&lt;p&gt;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 &lt;strong&gt;verify → store → ACK → process async&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Route('/webhooks/{providerSlug}', name: 'billing_webhook', methods: ['POST'])]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$providerSlug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;providers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fromSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$providerSlug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// …&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$identity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$provider&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;verifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// signature check on the RAW body&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WebhookVerificationFailed&lt;/span&gt; &lt;span class="nv"&gt;$exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'error'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Invalid webhook signature or payload.'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;webhookEvents&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findOneByProviderEventId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$provider&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$identity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$existing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$existing&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isProcessed&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'already processed'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;// Stored but not (successfully) processed yet: requeue it.&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;bus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ProcessWebhookEvent&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$existing&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'requeued'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebhookEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$provider&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$identity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$identity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$identity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// persist + dispatch…&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verify the signature on the raw request body.&lt;/strong&gt; Any framework normalization (decoding, re-encoding) breaks HMAC comparison. This is the most common webhook bug I've seen in the wild.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency lives in the database&lt;/strong&gt;, as a unique constraint on &lt;code&gt;(provider, event_id)&lt;/code&gt; — not in application logic. Two PHP-FPM workers receiving the same delivery concurrently will both pass an &lt;code&gt;if&lt;/code&gt; check; only one will survive the constraint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ACK before processing.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with replayed fixtures.&lt;/strong&gt; The suite replays full billing lifecycles from &lt;em&gt;signed&lt;/em&gt; webhook fixtures — including duplicates and out-of-order deliveries. Those are test cases, not incidents.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Multi-tenancy: a Doctrine filter, and the tests that earn the trust
&lt;/h2&gt;

&lt;p&gt;For a team-based SaaS, the pragmatic model is single-database with an &lt;code&gt;organization_id&lt;/code&gt; column. The risk is the day someone writes a query and forgets the &lt;code&gt;WHERE&lt;/code&gt;. Doctrine's SQLFilter closes that hole globally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrganizationFilter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;SQLFilter&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;addFilterConstraint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ClassMetadata&lt;/span&gt; &lt;span class="nv"&gt;$targetEntity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$targetTableAlias&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$targetEntity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getReflectionClass&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;implementsInterface&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrganizationOwnedInterface&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;\sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%s.organization_id = %s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$targetTableAlias&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'organization_id'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A request subscriber enables the filter as soon as an authenticated user with a current organization is detected. Every query against an entity implementing &lt;code&gt;OrganizationOwnedInterface&lt;/code&gt; gets scoped automatically — ORM queries, lazy-loads, joins.&lt;/p&gt;

&lt;p&gt;Two honest caveats, because this pattern is often oversold:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Console commands and Messenger workers run unfiltered.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A filter you haven't tested is a hope, not a guarantee.&lt;/strong&gt; 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 &lt;em&gt;before&lt;/em&gt; you trust the filter.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Plan gating then becomes declarative — an attribute on the controller, and a listener that redirects lower plans to the billing page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[RequiresPlan('pro')]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;advancedReports&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. The AI module: HttpClient, not an SDK
&lt;/h2&gt;

&lt;p&gt;Every SaaS I talk to is adding an AI feature, so the foundation ships a Claude integration. Deliberate choice: &lt;strong&gt;no vendor SDK&lt;/strong&gt; — 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.&lt;/p&gt;

&lt;p&gt;The pattern I use most is structured extraction via &lt;strong&gt;forced tool use&lt;/strong&gt;. You give the model exactly one tool whose input schema is your target structure, and you force it to call that tool. The answer &lt;em&gt;is&lt;/em&gt; the structure — never prose around it, never "Here's your JSON:":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;aiClient&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// tool_choice forces the tool&lt;/span&gt;

&lt;span class="nv"&gt;$input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;toolInput&lt;/span&gt;
    &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AiRequestFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;\sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'The model did not call the "%s" tool.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$tool&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tool&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;requiredProperties&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$property&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;\array_key_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$property&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AiRequestFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;\sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'The extraction is missing the required "%s" property.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$property&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ExtractionResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;One more 2026 reality: developers buy code they'll modify &lt;em&gt;with an agent next to them&lt;/em&gt;. The repo ships a &lt;code&gt;CLAUDE.md&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Quality as a feature, not a slogan
&lt;/h2&gt;

&lt;p&gt;Numbers from the codebase as of this week: &lt;strong&gt;379 tests, 1,667 assertions, PHPStan at level max with zero errors&lt;/strong&gt;, CS-Fixer, and a CI pipeline (lint + static analysis + tests against a real PostgreSQL service).&lt;/p&gt;

&lt;p&gt;The non-obvious lesson: the test suite changed &lt;em&gt;what I dared to build&lt;/em&gt;. 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.&lt;/p&gt;

&lt;p&gt;The other quality feature is rehearsed installation. &lt;code&gt;make init&lt;/code&gt; 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 &lt;code&gt;validate_timestamps=0&lt;/code&gt; and what that implies for deploys) — because "works on my machine" is where most boilerplates quietly end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell you to steal
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;An &lt;strong&gt;interface in front of your payment provider&lt;/strong&gt;, with a fake implementation — even if you only ever use Stripe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify → store idempotently → ACK → process async&lt;/strong&gt; for every webhook, with the unique constraint in the database.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Doctrine SQLFilter for tenancy&lt;/strong&gt;, plus the isolation tests that make it trustworthy — and explicit scoping in CLI/workers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forced tool use&lt;/strong&gt; for any LLM feature that feeds your domain logic.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;CLAUDE.md&lt;/code&gt; in any codebase another human (or agent) will inherit.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Full disclosure: this article comes out of building &lt;a href="https://shipanvil.com" rel="noopener noreferrer"&gt;ShipAnvil&lt;/a&gt;, 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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
