Quality Assurance

Defence Before Fix: Preventing Bug Classes with Static Analysis

15 min readBy Joseph Edmonds

Your test suite is green. TypeScript is satisfied. PHPStan reports zero errors. CI passes. And somewhere in production, a customer's payment silently failed, their data shows blank where their name should be, or a permission check quietly granted access it should have denied. This is the most dangerous class of software bug: not the crash that triggers an alert, but the failure that keeps running while producing wrong results. The cause is almost always the same — code written to hide errors rather than handle them.

The Error-Hiding Patterns

Three patterns account for the vast majority of silent failures in PHP and TypeScript codebases. They are so common that developers write them reflexively, often without recognising the danger.

Pattern 1: The Silent Default

Null coalescing to a falsy value is the most pervasive form of error hiding. It looks like defensive programming. It is the opposite.

<?php
// Anti-pattern: converts a bug into empty data
$customerName = $order->getCustomer()->getName() ?? '';
$emailBody = "Dear {$customerName},\n\nYour order has shipped.";

// When getName() returns null because of a bug:
// "Dear ,\n\nYour order has shipped."
// The email sends. The test passes. The customer is confused.
// Anti-pattern: same problem in TypeScript
const customerName = order.customer?.name ?? '';
const emailBody = 'Dear ' + customerName + ', your order has shipped.';

// When name is undefined due to a data mapping bug:
// "Dear , your order has shipped."
// TypeScript is satisfied. The test passes. The customer gets a broken email.

The distinction between "this value is legitimately empty" and "this value is missing because of a bug" has been erased. A renamed API field, a failed database lookup, a wrong property path — all produce the same result: empty string. And empty string looks valid enough to pass any test that checks "does this return a string".

Pattern 2: The Empty Catch

Exception handling exists so that errors propagate up the call stack until something can meaningfully deal with them. An empty catch block does the opposite — it intercepts the error and discards it.

<?php
// Anti-pattern: the payment disappears silently
try {
    $this->paymentGateway->charge($order->getAmount(), $card);
    $order->markAsPaid();
} catch (\Exception $e) {
    // TODO: handle this properly
}

// markAsPaid() never runs. The exception is gone.
// The user sees nothing unusual. No error, no retry.
// The payment never happened.

These originate as temporary scaffolding during rapid development. "I'll add proper handling later." Later never comes because the code appears to work — no uncaught exceptions, no test failures. The bomb ticks silently.

Pattern 3: Implicit Type Coercion

Languages that perform implicit coercion absorb type mismatches instead of raising errors. PHP without strict_types will convert the integer 42 to the string "42" rather than flagging a type error at the function boundary where they collide.

<?php
// Without strict_types, PHP silently coerces
function processOrderId(string $id): void
{
    // $id becomes "42" even when called with the integer 42
    // The type bug at the call site is invisible
}

processOrderId(42); // No error. No warning. Silently wrong.
<?php
declare(strict_types=1);

// With strict_types, the bug surfaces immediately
processOrderId(42);
// Fatal error: Argument 1 must be of type string, int given

Strict typing turns every function signature into a validation checkpoint. Type mismatches are caught at the point where they occur, not three layers downstream when the wrong data shape finally produces unexpected behaviour.

Why Green Tests Lie

These three patterns create a compounding effect that corrupts the value of your test suite. Silent defaults hide missing data at one layer. Loose types allow the wrong data shape through the next. Empty catches swallow the exception that would have revealed the problem at the third layer.

The result is a system where every test passes because every error is converted into a valid-looking result. A test that checks "the API returns a string" passes whether that string is the customer's real name or an empty string caused by a renamed field. The test is technically correct and practically useless.

This is worse than having no tests. Untested code is obviously unverified. Code covered by error-hiding tests produces active false confidence — the conviction that "the tests pass, so it works." That conviction is what allows silent data corruption to run for weeks before a human notices something is wrong.

The debugging economics are brutal. Error-hiding code takes 5–10x longer to diagnose because the error and the symptom are separated by layers of silent conversions. The database returned null at layer 1. The null became empty string at layer 2. Empty string was treated as "no value configured" at layer 3. The wrong behaviour surfaced at layer 4. Tracing that chain backwards is archaeology.

Defence Before Fix

The conventional response to a discovered bug is: write a failing test, fix the code, verify the test passes. This is good practice. It is also incomplete.

A test catches one specific manifestation of a bug. If that bug was caused by a systemic pattern — ?? '' used across dozens of files — fixing one instance does nothing about the others scattered through the codebase, written by different developers at different times, in code that has never been tested.

Defence Before Fix adds one step before the test:

  1. Analyse the pattern. What coding pattern allowed this bug to exist? Is this a one-off mistake, or is it a class of mistakes the codebase may contain more of?
  2. Defend against the class. Create a static analysis rule that catches every instance of this pattern — across the entire codebase, on every future commit, for every future developer.
  3. Then write the failing test for the specific bug.
  4. Then fix the specific bug.

The key leverage: a static analysis rule is a force multiplier. A test catches one bug in one file. A lint rule catches every future instance of that bug pattern — including instances that already exist in untested code paths, and instances that have not been written yet.

Why Static Analysis Before Tests?

This is not an argument against testing. Tests are essential. But they operate at different levels:

  • Static analysis asks: "Does this code contain patterns known to cause bugs?"
  • Tests ask: "Does this code produce correct output for specific inputs?"

Static analysis is preventive medicine. Tests are diagnostic. Static analysis runs on every file in every build — it cannot miss a file because nobody thought to write a test for it. A developer who writes ?? '' gets immediate feedback from the linter before the code is even committed. They would only get feedback from a test if someone had specifically written a test covering that null path, in which case the pattern probably would not have spread through the codebase in the first place.

The QA Hierarchy

Defence Before Fix sits within a broader quality hierarchy where each level must pass before the next is attempted:

  1. Static analysis — Type checking, linting, custom rules
  2. Automated tests — Unit, integration, functional
  3. Build verification — Services start, dependencies resolve
  4. Human acceptance testing — Visual review, workflow validation

This ordering prevents a common failure mode: running integration tests on code that does not type-check, or reviewing code with unresolved linting errors. Failures at lower levels produce confusing results at higher levels. A test that fails because of a type coercion issue in production code will send you debugging in the wrong direction. Run static analysis first, always.

In practice, this hierarchy is enforced by your CI pipeline. Static analysis runs first and blocks everything else if it fails. Tests run only when static analysis is clean. Human review happens only when CI is green.

Your Static Analysis Arsenal

Excellent static analysis tooling exists for PHP, TypeScript, and Python. The problem is that the defaults are too permissive. Getting real value requires deliberately tightening them.

PHP: strict_types and PHPStan at Level Max

The single highest-value change in a PHP codebase is adding declare(strict_types=1) to every file. Without it, PHPStan cannot provide accurate type analysis even at the highest level, because PHP's coercive type system makes every type annotation approximate.

<?php
declare(strict_types=1);

PHPStan at level max (level 10 in PHPStan 2.x) enables the full suite: dead code detection, impossible type combinations, strict null analysis, and more. The phpstan-strict-rules extension adds further checks including enforcement of strict comparisons (=== over ==).

# phpstan.neon
parameters:
    level: max
    paths:
        - src
    strictRules: true
    checkMissingIterableValueType: true

TypeScript: Beyond strict: true

strict: true is table stakes. Additional compiler flags catch entire classes of runtime errors that base strict mode misses:

{
    "compilerOptions": {
        "strict": true,
        "noUncheckedIndexedAccess": true,
        "exactOptionalPropertyTypes": true,
        "noImplicitReturns": true,
        "noFallthroughCasesInSwitch": true
    }
}

noUncheckedIndexedAccess makes array and object index access return T | undefined instead of T, forcing you to handle the case where the index does not exist. This catches an entire class of "cannot read property of undefined" runtime errors at compile time.

exactOptionalPropertyTypes distinguishes between a property that is absent and one explicitly set to undefined. Without it, TypeScript treats them identically, hiding bugs in API integrations where the distinction matters.

Python: mypy in Strict Mode

Python's optional typing becomes a genuine static analysis tool only when mypy runs in strict mode. The defaults are too permissive to catch meaningful bugs:

[mypy]
strict = true
warn_unreachable = true
warn_unused_ignores = true

warn_unused_ignores ensures that # type: ignore comments which are no longer necessary get cleaned up, preventing the gradual accumulation of silenced warnings that reintroduces type unsafety over time.

Writing Custom Rules: Where the Real Leverage Lives

Off-the-shelf static analysis catches generic mistakes. But the most dangerous patterns in a codebase are often domain-specific or team-specific — patterns that general-purpose rules will never flag because they are not universally wrong, only wrong in your specific context.

Custom rules are where static analysis becomes genuinely powerful. Each one encodes hard-won engineering knowledge — a lesson from a production incident, a pattern identified in code review, an anti-pattern that keeps appearing despite documentation — and converts it into automation. Not through documentation that nobody reads, but through a build error that blocks the commit and explains why.

PHPStan Custom Rule: Banning Null Coalescing to Empty String

PHPStan custom rules implement the Rule interface and operate on AST nodes. Here is a rule that bans $value ?? '':

<?php
declare(strict_types=1);

namespace App\QA\PHPStan;

use PhpParser\Node;
use PhpParser\Node\Expr\BinaryOp\Coalesce;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
 * Bans null coalescing to empty string: $value ?? ''
 *
 * This pattern hides bugs by converting missing data into empty data.
 * Handle null explicitly to surface bugs at their source.
 *
 * @implements Rule<Coalesce>
 */
final class NoNullCoalesceToEmptyStringRule implements Rule
{
    public function getNodeType(): string
    {
        return Coalesce::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        assert($node instanceof Coalesce);

        if (
            $node->right instanceof Node\Scalar\String_
            && $node->right->value === ''
        ) {
            return [
                RuleErrorBuilder::message(
                    "Null coalescing to empty string (?? '') hides missing data. "
                    . 'Handle null explicitly or use a non-empty default that signals intent.'
                )->build(),
            ];
        }

        return [];
    }
}

Register it in your PHPStan configuration:

# phpstan.neon
services:
    -
        class: App\QA\PHPStan\NoNullCoalesceToEmptyStringRule
        tags:
            - phpstan.rules.rule

ESLint Custom Rule: The Same Ban in TypeScript

ESLint rules operate on the JavaScript/TypeScript AST. The equivalent rule for TypeScript codebases:

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
    meta: {
        type: 'problem',
        docs: {
            description: 'Disallow null coalescing to empty string (?? "")',
        },
        messages: {
            noEmptyStringFallback:
                'Null coalescing to empty string hides missing data. '
                + 'Handle null explicitly or use a meaningful default.',
        },
        schema: [],
    },
    create(context) {
        return {
            LogicalExpression(node) {
                if (
                    node.operator === '??' &&
                    node.right.type === 'Literal' &&
                    node.right.value === ''
                ) {
                    context.report({ node, messageId: 'noEmptyStringFallback' });
                }
            },
        };
    },
};

Banning Empty Catch Blocks in PHP

A PHPStan rule targeting Catch_ nodes catches empty exception handlers before they ship:

<?php
declare(strict_types=1);

namespace App\QA\PHPStan;

use PhpParser\Node;
use PhpParser\Node\Stmt\Catch_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/** @implements Rule<Catch_> */
final class NoEmptyCatchRule implements Rule
{
    public function getNodeType(): string
    {
        return Catch_::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        assert($node instanceof Catch_);

        if (count($node->stmts) === 0) {
            return [
                RuleErrorBuilder::message(
                    'Empty catch block silently swallows exceptions. '
                    . 'Log, rethrow, or handle the exception explicitly.'
                )->build(),
            ];
        }

        return [];
    }
}

For TypeScript, ESLint's built-in no-empty rule covers this. Set allowEmptyCatch: false and enable it at error level.

The Bug-to-Rule Pipeline in Practice

Defence Before Fix is a concrete process, not just a philosophy. Here is a worked example.

Incident: Customer emails arriving with blank names

A support ticket arrives: customers are receiving emails that begin "Dear ," — the name field is blank. You trace it to this code:

<?php
$name = $this->customerRepository->find($id)?->getFullName() ?? '';
$email->setBody("Dear {$name},\n\n{$body}");

The customer had been soft-deleted. find() returned null. getFullName() was never reached. ?? '' converted null into empty string. The email sent successfully from the application's perspective. No exception was thrown. No test caught it.

Step 1 — Analyse the pattern. This is not a one-off. The ?? '' pattern appears throughout the codebase as a standard approach to nullable return values. Every instance is a potential silent failure of the same type.

Step 2 — Defend against the class. Write the PHPStan rule above, then run it against the full codebase:

vendor/bin/phpstan analyse src/

 [ERROR] Found 23 errors

  src/Email/OrderNotification.php:47
  Null coalescing to empty string (?? '') hides missing data.

  src/Report/CustomerSummary.php:83
  Null coalescing to empty string (?? '') hides missing data.

  ... 21 more instances

23 instances. The one you found was a symptom. The other 22 are bugs waiting to surface in different contexts, reported by different customers, at different times.

Step 3 — Write the failing test for the original bug. A deleted customer's order should throw an exception when the email is prepared, not send a blank-named message.

Step 4 — Fix all 23 instances. Each one requires a deliberate decision: throw an exception, return a meaningful non-empty default that signals intent, or propagate null explicitly. The decision is now forced into the open rather than silently made by the language runtime.

After this process: one static analysis rule that prevents this class of bug permanently, one test that documents the correct behaviour, and 23 latent bugs fixed rather than one.

Custom Rules as Institutional Memory

The broader value of custom static analysis rules is that they convert institutional knowledge into automation.

In most engineering teams, hard-won lessons live in the heads of senior engineers, in code review comments that scroll off the screen, in post-mortems nobody re-reads, and in documentation that becomes outdated. A new developer joins six months later and makes the same mistake, because the only record of why it is wrong is buried in a GitHub thread from before they arrived.

A custom lint rule is different. It is always current. It runs on every commit. It reaches every developer regardless of seniority or tenure. It does not require anyone to remember to mention it in code review. When a new developer writes $value ?? '', they get a build error that explains why the pattern is dangerous and what to do instead.

Each custom rule is a lesson that does not need to be taught again. Over time, a codebase accumulates rules that encode the team's collective experience: the external API that returns null instead of throwing on missing records, the configuration value that must never silently default for security reasons, the third-party library whose exceptions must always be re-wrapped before propagating. These rules create an environment where the mistakes of the past are structurally impossible to repeat.

The quality of the error message matters. A rule that says "pattern X detected" teaches nothing. A rule that explains why the pattern is dangerous and suggests a safe alternative teaches the developer something permanent. The best custom rules are opinionated documentation encoded as automation.

Rules That Reason Across the Whole Codebase

Most static analysis rules examine a single file in isolation. But some of the most valuable custom rules cross file boundaries — they check whether code is properly connected to the rest of the system, not just whether it is internally correct.

The sharpest illustration of why this matters: a service that is entirely correct, thoroughly tested, and never called in production.

On a production Symfony project processing supplier product data, a preprocessing service existed with working SQL logic and a green test suite. But it was never injected as a constructor dependency into the pipeline meant to call it. Symfony's autowiring did not wire it in automatically; the service lived in isolation. During a scheduled Christmas shutdown where stock quantities were zeroed, prices the service should have cleared stayed set — because the service was never wired in. The code was correct. The tests verified the code. The pipeline never ran it.

A custom PHPStan rule now catches this entire class of failure. At analysis time, it runs grep across the production source directory to check whether a class is actually used anywhere as a dependency:

<?php
// Simplified from a production PHPStan rule.
// Detects service classes used in tests but never in production code.
public function processNode(Node $node, Scope $scope): array
{
    $className = $node->getClassReflection()->getName();
    $shortName  = $this->getShortClassName($className);

    // grep production src/ for this class used as a constructor dependency
    exec(
        sprintf('grep -rlE "%s" src/ 2>/dev/null', $shortName),
        $output,
        $exitCode
    );

    $usedInProduction = (0 === $exitCode && [] !== $output);

    if (!$usedInProduction && $this->isUsedInTests($className)) {
        return [
            RuleErrorBuilder::message(sprintf(
                'Service %s is only used in tests, never in production code. '
                . 'Ensure this class is injected into the pipeline that calls it.',
                $shortName
            ))->build(),
        ];
    }

    return [];
}

This catches a type of bug that no test can reach. Tests exercise the service class directly and correctly. Only something that reasons about the full production dependency graph can detect that the service is never invoked when the application actually runs. Green tests. Working code. Zero production usage.

Production codebases with many rules of this type tend to develop rule clusters — a suite of complementary rules that enforce a single pattern from multiple angles. A domain-specific database access pattern, for example, might accumulate: a rule that prevents query objects being created inside loops, a companion rule that catches prepared statements also being created inside loops, a third that detects a prepared statement used only once in a method body (it should be a simpler query class instead), and a fourth that requires a performance-monitoring dependency be injected into every prepared statement class. Each rule catches a different failure mode of the same pattern. Together they make misuse structurally difficult.

The same cross-file analysis approach works in TypeScript. An ESLint rule can read route definitions from a separate file at lint time and validate every internal link reference against those registered routes. If a developer renames a route without updating all references, the build fails — without any test needing to cover that navigation path:

const fs   = require('fs');
const path = require('path');

// Load valid routes from the route registry at lint time
const routeSource = fs.readFileSync(path.resolve('src/routes.ts'), 'utf8');
const validRoutes  = parseRoutesFromSource(routeSource);

module.exports = {
    create(context) {
        return {
            Property(node) {
                if (isLinkProp(node) && node.value.type === 'Literal') {
                    const href = node.value.value;
                    if (typeof href === 'string' && href.startsWith('/') && !validRoutes.has(href)) {
                        context.report({
                            node,
                            message: 'Link "' + href + '" points to a route that does not exist.',
                        });
                    }
                }
            },
        };
    },
};

Rules that grep the codebase or read external files are more expensive to write and slower to run than single-file rules. Write them for failure modes that are severe and that tests genuinely cannot reach: services disconnected from pipelines, broken internal navigation, sitemap documentation that has drifted from implemented pages. These are the bugs that slip through green test suites because tests model code in isolation — not how the full system is assembled and wired.

The Ratchet Effect

The goal is not zero bugs — that is not achievable. The goal is that every bug makes the system more resilient. Each production incident leaves behind not just a fix and a test, but a defence. The categories of bugs that can survive in the codebase shrink over time. The quality ratchet only turns one way.

A codebase with mature custom static analysis rules has a different character from one without. Code review focuses on logic and architecture rather than catching patterns the linter could find automatically. New developers are constrained to the team's established safe patterns from their first commit. Silent failures become structurally harder to introduce, because the patterns that cause them are banned at the tool level.

Start with what you have. Enable strict: true in TypeScript and add the additional compiler flags. Add declare(strict_types=1) to PHP files and enable PHPStan at max level. Enable mypy strict in Python projects. These steps alone will surface a backlog of latent bugs that exist right now in tested, passing code.

Then, the next time a bug reaches production — before you write the test — ask the question: what pattern allowed this to happen? Can a machine detect every future instance of this pattern automatically? If yes, and it usually is, write the rule first. Fix the bug second. Leave the codebase permanently better than you found it.