Deleting a module

The subscriptions module was built in 2022 for a product line that was discontinued in 2024. Two years later it is eight thousand lines, six database tables, two scheduled jobs and a set of layer rules that everything else has to accommodate — because discontinuing a product is a business decision with no engineering task attached to it.

The symptom

$ find src/Subscriptions -name '*.php' | xargs wc -l | tail -1
  8,104 total

$ ./bin/table-rows 'subscription%'
  subscriptions 1,204   subscription_periods 14,882
  subscription_usage 4,102,884   ... 3 more

$ mysql -e "SELECT MAX(created_at) FROM subscriptions"
2024-03-11
and what is still running: a nightly recalculation over
1,204 cancelled subscriptions, a metering job writing to
subscription_usage every five minutes for nothing, an
event published on every order and consumed by a
listener in this module, and a layer rule permitting
four other modules to depend on it.

The metering job has written four million rows since the last subscription was cancelled, which is the clearest indication that nothing is reading them. Nothing has failed, which is why it survived — a module that works and is not needed is invisible to every check.

Why it happens

A product being discontinued produces a decision in a meeting and a note in a spreadsheet, and there is no mechanism by which that becomes a ticket to remove the code. The code keeps working, which is what code does.

The fix

Establishing that it is genuinely dead

# 1. routes
$ ./bin/route-usage --since=365d | grep -c subscription
0

# 2. logs — 188 lines, all of them the metering job's
#    own "wrote 0 rows"

# 3. references from outside
$ grep -rn 'App\Subscriptions' src/ --exclude-dir=Subscriptions
  src/Orders/OrderPlaced.php:41        an event listener
  src/Http/Middleware/Entitlements.php:22
  src/Console/Kernel.php:14            the two jobs

Three independent checks, and the third is the one people skip because the routing already said no. All three had to be clean before anything was deleted, and the third produced the only surprises — three call sites, all of them incidental.

The three things that still used it

// an entitlements middleware, on every request
$entitlements = $this->subscriptions->entitlementsFor($request->user());

if (! $entitlements->allows($request->route()->getName())) {
    abort(403);
}

// with every subscription cancelled it returns an empty
// set, and the check passes because the route names are
// not in the restricted list. it has permitted
// everything since 2024.

A middleware on every request, consulting a module with no data, permitting everything — which is correct behaviour and had been an authorisation check in 2022. Replacing it with the interface’s no-op implementation was the seam that made the deletion possible at all.

// the seam, from 2022, for a reason that turned out
// to be secondary
interface SubscriptionEngine
{
    public function entitlementsFor(?User $user): Entitlements;
}

final class EverythingEnabled implements SubscriptionEngine
{
    public function entitlementsFor(?User $u): Entitlements
    {
        return Entitlements::all();
    }
}

The data

the same three questions as the retention work:

  legal     the records evidence payments taken. seven
            years from the last one, which is 2031.
  contract  two years, and it has been two.
  finance   "can I still get the 2023 figures?"

the answer: subscriptions, subscription_periods and
the payment linkage exported to the archive and the
tables dropped; subscription_usage, 4.1M operational
rows with no retention requirement, dropped.

the export: 41 MB of CSV offsite, with a schema
description alongside it.

Exporting to a flat file rather than keeping the tables is the decision that makes this a deletion rather than a soft one, and the schema description next to the CSV is what makes it readable in 2031. A dropped table with an export nobody can interpret is the same as no export, and the description is two pages naming every column, its type, and what the enumerated values meant — which is the part that will not be reconstructible from anything else once the code is gone.

The order of deletion

  1  the scheduled jobs, disabled. wait a week —
     nothing failed, which is the point of waiting.
  2  the event listener, then the event.
  3  the middleware, replaced by the no-op.
  4  the routes, then the code and its tests.
  5  the layer rules, then the data export.
  6  the tables.

reversed by a revert up to step 4, and by a restore
after step 6.

Jobs first and tables last keeps the rollback cheap for as long as possible, and the week between the first two steps is the only part that is not obvious. Nothing failed during it, which is a weak signal and the only one available for a job that runs nightly.

The shared kernel that shrank

$ ./bin/shared-kernel-references
  BillingPeriod, ProrationCalculator, RecurrenceRule,
  UsageWindow, MeteringUnit, EntitlementSet      0
  Money 412   DateRange 188   ...

# six of twenty-two classes lost their only consumer.

A shared kernel accumulates by the two-consumer rule and does not shed by it, because nobody re-checks when a consumer disappears. The six were moved into Billing, which is now their only user, and the kernel is sixteen classes — a query that could run monthly and does not.

The layer rules

# before
  Orders:        [Shared, SubscriptionsApi]
  Catalogue:     [Shared, SubscriptionsApi]
  Billing:       [Shared, OrdersApi, SubscriptionsApi]
  Subscriptions: [Shared, OrdersApi, CatalogueApi]

# after
  Orders:    [Shared]
  Catalogue: [Shared]
  Billing:   [Shared, OrdersApi]

Three modules depending on a fourth becomes three depending on nothing extra, and the rule set halves. Subscriptions also depended back on two of the three, which was a cycle everybody had accepted because untangling it was three weeks — and deleting the module removed it for free.

Verifying it worked

$ git diff --stat main | tail -1
 188 files changed, 62 insertions(+), 9,204 deletions(-)

$ vendor/bin/phpunit && vendor/bin/deptrac --fail-on-uncovered
  Tests: 1,604 passed        # was 1,688
  Violations 0, uncovered 0

# a month of production
$ grep -rc 'Subscription' /var/log/app/*.log | 
    awk -F: '{s+=$2} END {print s}'
0

$ ./bin/response-times --compare=2026-05
  p50 unchanged. p95 -4ms (the middleware).

Four milliseconds off the p95 is the middleware no longer consulting a module on every request, which nobody had costed and which is the only performance effect of deleting eight thousand lines. A month of logs with no reference is the check that nothing constructs any of it by a path the three searches missed.

What this costs

A deletion nobody will thank you for. There is no feature, no fix and no visible improvement — the argument is that every future change now has eight thousand fewer lines to be compatible with, which is real and is not demonstrable.

An archive of the data that is a CSV in an object store, whose readability in 2031 depends on a schema description written by somebody who will not be asked. That is the weakest part of this and it is better than keeping six tables and two jobs running for seven years to preserve the option.