Most Symfony audits I get hired for arrive in the same shape. Someone on the leadership side has a bad feeling. A senior engineer has just left. A vendor pitch is on the table. The board is asking pointed questions about technical risk. The codebase has not been opened by a fresh pair of eyes in years, and nobody internal can give a confident answer to “is this thing in good shape or not?”
This essay is the checklist I run when that question lands on my desk. It is not exhaustive. It is the seven layers that, in my experience, capture more than 90 percent of the risk in a typical Symfony codebase. Each one takes from 30 minutes to a few hours. The whole thing, done seriously, is a week. Done as a smoke test, it is one afternoon. Both versions are worth doing before any major investment decision.
Layer 1: dependency and version hygiene
Open composer.json and composer.lock. Look for three things.
PHP version. Anything below 8.2 has been past end of life for security since late 2025 (see PHP supported versions). Apps on 8.0 or 7.x are not “legacy”, they are a compliance issue. The cost of upgrading PHP is finite. The cost of running an unpatched runtime on a payment-handling system is not.
Symfony version. Anything below 6.4 LTS is out of community support. 7.x is the current major track. A codebase that has skipped two majors in a row is usually painful but not catastrophic; a codebase still on 4.x or 5.x is a project, not an audit finding.
Abandoned packages. composer audit and composer outdated --direct together surface most of it. Anything in the dependency tree that is unmaintained, has a known CVE, or is forked in the project’s own vendor directory is a finding worth a paragraph in the report. Bonus: check whether dev-master or git refs are pinned anywhere. They usually are not, but when they are, they explain a lot.
Layer 2: static analysis posture
Run vendor/bin/phpstan analyse --level=max against src/. The output is the audit’s single most informative artifact. Three patterns to read for:
- Errors per file, sorted. The top five files are where the next bug will come from. Always. Note them.
- Suppressed rules. Look for
phpstan-ignore-lineand@phpstan-ignoreannotations across the repo. Frequency and clustering matter more than the count. Twenty suppressions in one risky service is a worse signal than 200 spread evenly across a large codebase. - Baseline file size. A long baseline is a deferred problem. Auditing a codebase whose baseline contains 3000 errors is auditing a codebase where the team has stopped enforcing PHPStan.
If PHPStan is not configured at all, that is the headline finding. Skip ahead.
Layer 3: security surface
I do not need a full pentest to write a useful security paragraph. I need to confirm five things.
- Symfony Security is in use, not a hand-rolled session. Grep for
UserPasswordHasherand the firewall config. Hand-rolled auth is almost always wrong. - CSRF is on for state-changing forms. Check
framework.csrf_protection: trueand_tokenusage in templates. - Content-Security-Policy header is set, with a nonce, not
unsafe-inline. Inspect the response in a browser. Read the relevant header. - Input is validated through Symfony Validator at the boundary. A controller that pulls raw
$request->get('email')into the domain layer is a smell. - Doctrine queries are parameterized. Grep for
->getQuery()next to string concatenation. It is rare, but when it appears it is a critical finding.
A clean five-point security paragraph is more useful than a 40-page tool report, because the leadership reading the audit can act on it. See also: The Honest Architecture Review for how I weight findings in the final report.
Layer 4: Doctrine and the database
This is where Symfony audits earn their fee. Database problems hide better than code problems and they kill more apps.
Pull the slow-query log for the last 14 days. Pull the EXPLAIN plan for the top 10 queries. Look for:
- Sequential scans on tables with more than 100k rows. Almost always a missing index.
- Repeated identical queries within a single request. N+1. Common after entity changes that did not update the fetch joins.
- Migrations with
down()methods that throw. Good, that is the convention here. Migrations withdown()methods that do not throw are a sign that someone ranmigrate:downin production at some point. Find out who. - Schema and code drift. Run
doctrine:schema:validate. The output should be empty. If it is not, the production schema is no longer the schema the entities describe, and you are one deploy away from a surprise.
The Doctrine Performance Patterns That Actually Matter post covers what to do with the findings.
Layer 5: test reality vs claimed coverage
Run the test suite. Note the number of tests, the runtime, and the failure rate on a cold checkout. Then look for the gap between coverage as a number and coverage as a property.
A repo can report 70 percent coverage and have zero useful tests if all of them are unit tests that mock the database, the queue, and the HTTP layer. The number matters less than the answer to: “if I break the most important user flow, will any test catch it?” Find the test that covers signup, login, or checkout, and inspect what it actually asserts. If the test only confirms that an exception was not thrown, coverage is theatre.
Also look for the flaky-suite signal: a .phpunit-rerun marker, retries in CI config, or commit messages that say “fix flaky test.” Each one is a debt entry. Each retry is a bug the team chose to mute.
Layer 6: deployment and rollback
Read the deploy script. It is usually in bin/, tools/, or a Makefile. Three questions:
- What is the downtime window? “Zero” is achievable, but only if the migration step uses an expand-contract pattern (see Zero-Downtime Doctrine Migrations). A deploy that runs
doctrine:migrations:migratesynchronously against a production DB while the app is serving traffic is a deploy that will, eventually, lock a table for long enough to page someone. - What is the rollback path? “Revert the commit and redeploy” is fine for code. It is not fine if the migration was destructive. Audit findings include any deploy whose forward step cannot be reversed without a backup restore.
- Are feature flags or canary deploys in use? If every change goes to 100 percent of traffic on first deploy, the team is one bad PR away from an incident that would otherwise have been a paging on a small slice.
Layer 7: observability
The last layer is the cheapest to add and the one most often missing. I look for:
- Structured logs with request IDs that propagate across services.
- APM (Tideways, New Relic, Datadog, whatever) with at least transaction tracing on the top endpoints.
- Error tracking (Sentry or equivalent) with unhandled exceptions wired up.
- A postmortem culture, however lightweight. See The Postmortem Format That Reduces Incidents.
A codebase without observability is a codebase where the team learns about incidents from the customer.
The 60-minute version
If you only have an hour:
composer outdated --directandcomposer audit.phpstan analyse --level=max srcand read the top 50 lines.doctrine:schema:validate.- Run the test suite and time it.
- Open the deploy script and read it cover to cover.
That hour produces 80 percent of what a full audit would say. The remaining 20 percent is what justifies a real engagement.
If you want a senior pair of eyes on a codebase before a major decision, our Technical Debt and architecture review engagements both lead with this checklist. The full report ends with a prioritized backlog you can actually fund.
References
- Symfony Security Bundle docs: the canonical reference for what “Symfony Security is in use” actually looks like.
- PHPStan rule level reference: what each level enforces, and why level 8+ is the bar for a healthy codebase.
- PostgreSQL EXPLAIN: how to read the plans from Layer 4 without misinterpreting them.
- OWASP Top 10: the security shape behind the five-point check in Layer 3.