A SaaS admin dashboard in plain Symfony: MRR, churn, signups — no admin bundle

We removed EasyAdmin from ShipAnvil and replaced it with 1,321 lines of plain controllers, Twig and Tailwind. The metrics that matter, the honest definitions behind them, and when you should (and shouldn't) do the same.

Every SaaS needs a back office, and the reflex is to reach for an admin bundle. ShipAnvil itself shipped EasyAdmin until this week — v1.2.0 (11 July 2026) removed it and replaced the whole back office with plain Symfony controllers, Twig templates and Tailwind. Final tally: 1,321 lines for everything — metrics dashboard, searchable paginated indexes over four entities, a support-grade user editor, impersonation with an audit log — and about 6 MB of vendor code gone. The dashboard itself, the part everyone actually looks at, is 264 lines.

This post is that build, with the real code and the reasoning — including the part where an admin bundle is the right call.

When a bundle wins (be honest about this)

If your back office needs twenty CRUDs by Thursday — content types, catalogs, taxonomies, operators editing everything — a generator is unbeatable and you should use one. That is the job it is built for.

A SaaS back office is usually not that. Ours boiled down to: one metrics dashboard, four read views (users, organizations, subscriptions, invoices), exactly two write actions on users, and impersonation. For that shape, the bundle inverts the cost: you spend your time in configuration classes, theme variables and route loaders to restrain a generic machine — more glue than the feature itself. Below the tipping point, plain Symfony is less code, and it is code you already know how to style, secure and test, because it works exactly like the rest of your app.

The three numbers a founder checks daily

Not thirty charts — three questions: how much recurring revenue, is it growing, who is leaving. One value object answers them:

final readonly class MetricsSnapshot
{
    public function __construct(
        /** Estimated monthly recurring revenue, in cents. */
        public int $mrr,
        public string $currency,
        /** Organizations whose subscription currently grants access (trials included). */
        public int $activeSubscriptions,
        public int $trialingSubscriptions,
        public int $pendingCancellations,
        /** Subscriptions that definitively ended in the last 30 days. */
        public int $churnedLast30Days,
        /** churned / (active + churned), null when there is no data. */
        public ?float $churnRate,
        public int $signupsLast30Days,
        public int $signupsPrevious30Days,
        public int $totalUsers,
    ) {
    }
}

The definitions are deliberately simple and deliberately documented, because every metrics dashboard lies a little and yours should lie out loud:

  • MRR is an estimate. It sums the configured monthly price of every organization's current subscription that grants access — trials excluded (they don't pay yet), grace-period cancellations included (they pay until the period ends). The number your provider actually charged lives in the provider dashboard; this figure is for steering, not accounting.
  • Churn is churned / (active + churned) over 30 days, where "churned" means definitively ended — a cancellation that still runs to the end of its period is a pendingCancellations, not a churn. Conflating the two makes bad weeks look worse and good saves invisible.
  • Signups come with their previous window (signupsPrevious30Days), because a raw count without a trend is a vanity number.

The computation is one pass over the local billing mirror — the subscription rows your webhook pipeline keeps in sync — so the dashboard makes zero provider API calls and renders in milliseconds:

foreach ($currentByOrganization as $subscription) {
    if (!$subscription->isActive()) {
        continue;
    }

    ++$active;

    if (SubscriptionStatus::Trialing === $subscription->getStatus()) {
        ++$trialing;
    } elseif ($this->planRegistry->has($subscription->getPlanId())) {
        $mrr += $this->planRegistry->get($subscription->getPlanId())->priceMonthly;
    }

    if ($subscription->isPendingCancellation()) {
        ++$pendingCancellations;
    }
}

Scanning the whole table in PHP is trivial at the scale an admin dashboard is used, and it reuses the exact same access rule (Subscription::isActive()) as the rest of the application — no second definition of "active" to drift out of sync.

The controller on top is barely a controller:

#[IsGranted('ROLE_ADMIN')]
final class DashboardController extends AbstractController
{
    #[Route('/admin', name: 'admin', methods: ['GET'])]
    public function __invoke(
        SaasMetrics $metrics,
        UserRepository $users,
        SubscriptionRepository $subscriptions,
    ): Response {
        return $this->render('admin/dashboard.html.twig', [
            'metrics' => $metrics->snapshot(),
            'recent_users' => $users->findMostRecent(5),
            'recent_subscriptions' => $subscriptions->findMostRecent(5),
        ]);
    }
}

The template is Tailwind utility classes — four stat tiles and two "latest" panels. Total for the tier: 264 lines, tests not included (they exist).

Index pages without a bundle: one query service and a Page object

The part people assume needs a bundle — searchable, filterable, paginated tables — is a query service and a 50-line value object. Every admin index goes through one class:

/**
 * @return Page<User>
 */
public function users(?string $search, ?bool $verified, ?bool $totpEnabled, int $page): Page
{
    $qb = $this->entityManager->createQueryBuilder()
        ->select('u')
        ->from(User::class, 'u')
        ->orderBy('u.createdAt', 'DESC');

    if (null !== $search && '' !== $search) {
        $qb->andWhere('LOWER(u.email) LIKE :search')
            ->setParameter('search', '%'.mb_strtolower($search).'%');
    }
    if (null !== $verified) {
        $qb->andWhere('u.verified = :verified')->setParameter('verified', $verified);
    }
    // …paginate with Doctrine's Paginator, wrap in Page
}

Page carries the items plus total, pageCount(), hasNext() — the four things a pagination footer needs — and one shared Twig partial renders it under every table, preserving the current filters by merging the query string. That's the whole "datagrid".

Two details earn their keep:

  • These queries live in the Admin module on purpose. They run across organizations — the tenant Doctrine filter is disabled for /admin requests — so they must never be reachable from tenant-scoped application code. Putting them on the shared entity repositories would make that a one-import mistake.
  • Doctrine's Paginator handles the join case (subscriptions join their organization for the search) without the fetch-join counting bugs a hand-rolled COUNT(*) hits.

Deliberate capabilities beat generated ones

A generator gives every entity the same verbs: create, edit, delete. A hand-rolled admin makes you decide, and the decisions are the feature:

  • No create on users — accounts go through registration (password hashing, email verification, provisioning). An admin-created user is a user who skipped your invariants.
  • Editing is two flags — verified and administrator. No email edits: the address is the user's identity and their login; support acts on flags, not identities. And you cannot revoke your own admin role — locking yourself out of the back office you are standing in is never a support task.
  • Subscriptions and invoices have no write routes at all. The payment provider is the source of truth and webhooks keep the mirror in sync; an edit here would be overwritten by the next event. Not "disabled buttons" — the routes do not exist, and the functional tests assert those URLs return 404. A route that doesn't exist is a stronger guarantee than a permission that says no.
  • No delete anywhere. Erasing a user touches memberships, subscriptions and invoices, and usually happens under a GDPR request with its own rules. That deserves a designed flow, not a generic button next to "edit".

Impersonation survives unchanged, because it never belonged to the bundle: Symfony's switch_user plus a voter (admins only, never onto another admin) plus a Monolog audit trail on every start and stop.

Test the doors, as always

The suite that guards this is ordinary functional testing: anonymous visitors are redirected to login, regular users get a 403 on /admin, admins see data across organizations, the write URLs that must not exist return 404, the edit form flips exactly the two flags it should, the impersonate action renders for regular users only, and an admin submitting their own edit form keeps their role. Nothing here needed a bundle-specific test harness — it's WebTestCase end to end.

Steal the numbers, or the whole thing

If you're building your own: start with MetricsSnapshot and the three honest definitions above — that's an afternoon, and it's the part your future self checks every morning. The rest is a query service, a Page object and templates in whatever design system your app already uses.

Or start from a codebase where it's already wired and tested: ShipAnvil ships this exact back office — 379 tests, 1,667 assertions, PHPStan at level max — next to auth, 2FA, teams and billing on two providers. See what's in the box or poke the live demo.