Back to blog
laravel security architecture notifications tokens

Two modules rolled their own share links, and they'd already drifted apart

The Starting Point

Two things blocked on one decision: public share links. Four callers want them and two already ship. Plus notification rules — the last unported piece of the notifications work, which I'd deferred back in early September with a single sentence: "if configurable triggers are wanted later they sit above this."

They were wanted.

What Actually Happened

Not a greenfield decision

Two of the four share-link callers already shipped, each having rolled its own. And they had already diverged:

  • one link could be revoked, the other could not
  • neither expired
  • neither was rate-limited
  • neither recorded that it was used
  • both stored the token raw — so a database dump hands over every live link

Nobody decided any of that. It's what building the same thing twice produces.

The rule that came out of it: a link permits one ability, on one record, for a while. Not a session and not a role.

And the migration of the two that existed is what proves it — if it cannot carry the two that exist, it will not carry the two that don't. Live links were carried over rather than cut off: existing tokens get hashed into the new table on migration.

What all four callers turn out to need and none had:

  • Hashed at rest. What was emailed is the only copy.
  • Expiry a module may not decline. Thirty days or ninety; never "never". An approval link that outlives its approval is the failure mode.
  • Rate limiting, because an unauthenticated URL with no throttle is an invitation to enumerate whatever the token length.
  • Every use audited, refusals included — a refused use is how a link being probed becomes visible at all.
  • Refusals silent and identical. Expired, revoked, exhausted and never-existed all 404, or the response is an oracle.
  • The business unit carried from the subject, so scoping works with nobody signed in. That's the thing easiest to get wrong without a user, and the reason this is middleware rather than four controllers remembering.

Three things the migration turned up:

The internal pages could no longer show the link — because there's nothing readable to show once it's hashed. Both now display it once, at the moment it's issued. And the link is issued before the send, so a failed send or a deployment with no mailbox still leaves something a person can copy.

Adding an approver never issued a link at all when no mailbox existed. The send returned early and nothing was minted. So the whole feature silently didn't happen on any deployment without email configured.

Resending briefly rotated twice in one action: the service minted one and the mailer immediately retired it for another. It worked, and it was nonsense.

And a framework detail worth the comment it got:

a public route with two segments fills a controller's non-injected
parameters positionally — so {token} has to be absorbed by the
signature or the model binding receives it

Same family as the positional route-parameter bug that cost me a morning a fortnight ago.

Notification rules, and why six of seven defects weren't work

The source's rules engine has seven defects. Six of them are downstream of two absences it could not help: it had no registry of record types, so a rule had to name a PHP class; and no registry of fields, so every field name was free text.

Its model dropdown is a six-entry constant that a required|string rule never checks against. A misspelled field is a rule that silently never fires, with no screen able to say so.

Those aren't defects to reproduce carefully. They're defects the platform cannot have, because both registries already exist. So a rule names a record type key and a field key, both validated.

The finding that mattered most is not performance. The source resolves recipients and sends, and nobody asks whether the person may see the record — while the notification quotes that record's own fields.

Recipients are filtered by the type's view permission now. And because silent filtering is how a rule comes to reach nobody, the run writes down what it told and what it dropped. A rule reaching no one looks different from a rule that has not fired.

It costs nothing when there are no rules. The source registers wildcard created/updated listeners and reads its whole trigger table on every model save in the application, rules or not. Here: four early returns, a per-request index, and models ignored unless a rule resolves them.

The static analyser was right about the docblock and wrong about reality

Worth recording. PHPStan told me a null check was redundant — "always true". It was correct about the docblock and wrong about reality: an unsaved model's property IS null, and taking the guard out made the create screen 500.

The porting guard caught it, not the analyser. The docblock was the thing that was wrong.

And a number I'd been repeating

I'd written "1,318 lines of configurable triggers" in a decision record and on the dashboard. Reading it properly: that's the whole module. The rules engine is 410 lines of logic behind a 704-line form, and 471 of the 1,318 is a notification list page core already has.

Not a big deal on its own, except that I'd quoted it as justification more than once.

What Worked

The vertical is a suite, not a fork. Real modules related by declared dependency, with one module carrying what the suite shares and integrations declaring they depend on it.

It needed nothing new — the module descriptor has carried dependency metadata since the module registry was built, and the resolver already walks it transitively. It had simply never been asked for more than one level.

Sub-modules were considered and are the wrong shape: a sub-module cannot be installed, released or entitled on its own, and entitlement per module is how this platform is sold.

Two modules left the vertical with that decision. One is core work filed under a vertical purely because of where it sat in the source. The other isn't a module at all — it's a record type with custom fields, which the inventory had guessed and is now confirmed.

A correction I'd repeated several times: share links do not gate the customer portal. A portal user signs in and has a session; a share link is the deliberate absence of an account. A portal built out of share links is a session with extra steps.

And encrypted secrets, recorded as an amendment rather than a reversal. The original decision rejected editable credentials on three specific grounds, and only one is answered by encryption alone:

  • the backup objection is answered — a backup holds ciphertext and the key isn't in it
  • the audit objection was already answered by construction — the settings controller has only ever recorded that settings changed, never to what
  • the export objection is not answered by encryption, and becomes a guard the code has to carry, since an export that decrypts defeats the point

Writing it as an amendment keeps the two-thirds that still stand.

What I Learned

  • Building the same thing twice produces divergence, not duplication. Mine drifted on revocability and nobody made that call.
  • Migrating the existing callers is the proof. If a shared mechanism can't carry what already exists, it won't carry what doesn't.
  • Make refusals identical and silent. Different responses for expired / revoked / never-existed turn your endpoint into an oracle.
  • Expiry that a caller may not decline. "Never" is not a valid option for a link sent by email.
  • A registry turns a class of defect into an impossibility. Six of seven bugs in the source's engine existed because it had nowhere to look up a type or a field.
  • Record what a rule dropped, not just what it sent. Silent filtering is how something ends up reaching nobody forever.
  • A static analyser trusts your docblock. If the docblock is wrong, the analyser will confidently tell you to delete a guard you need.
  • Amend a decision rather than reversing it when only part of its reasoning has been answered. Reversal loses the two thirds that still hold.
  • Check the number you keep quoting. Mine was three times the real one.

Tech Notes

  • Stack: PHP 8.4, Laravel, MySQL (Docker), SQLite suites, PHPStan level 6
  • Share links: hashed at rest (same approach as the API tokens the platform already uses), one ability + one subject + expiry, rate-limited, every use and refusal audited, unit carried from the subject, enforced in middleware
  • Notification rules: record-type key + field key validated against registries; conditions on values not just "changed"; date rules; per-rule cooldown; recipients filtered by view permission with counts of told/dropped
  • Four early returns and a per-request index so rules cost nothing when none exist
  • bin/check green at 1,264 tests
Zero Focus Dev

Zero Focus Dev

Breaking things, learning fast, sharing it all.