A deep day, not a wide one. The bulk of the work was a sustained platform build where the same architectural idea kept showing up over and over: put a seam between the thing you do and the thing you store it in, so the backend becomes swappable later without touching the callers. Around that, two smaller but sharp lessons — a tracking-link bug that only bites signed URLs, and a clean way to make app settings admin-editable without abandoning Laravel's config(). I split each of those into its own focused post; here's the short version of all three threads.
Thread 1 — When the same pattern shows up 50 times, it's an architecture, not a coincidence
Most of the day went into an observability platform that ingests errors and metrics from a lot of different runtimes. The interesting part wasn't any single feature — it was that nearly every storage concern landed the same way: a small Store contract plus an Eloquent driver behind it. Occurrences, metrics, APM transactions, RUM web-vitals, billing, tenant connection resolution — each got its own seam.
The shape is always this:
interface MetricStore
{
public function record(MetricSample $sample): void;
/** @return iterable<MetricPoint> */
public function series(ProjectId $project, TimeRange $range): iterable;
}
final class EloquentMetricStore implements MetricStore
{
public function record(MetricSample $sample): void
{
Metric::create($sample->toAttributes());
}
public function series(ProjectId $project, TimeRange $range): iterable
{
return Metric::query()
->forProject($project)
->whereBetween('recorded_at', $range->toBounds())
->orderBy('recorded_at')
->cursor();
}
}
The callers — dashboards, the ingest pipeline, the analytics queries — only ever talk to the interface. The first driver is Eloquent on Postgres because that's the boring, reliable default. But the seam means a high-write path (occurrences, web vitals) can move to a columnar store later by writing a second driver and rebinding it in a service provider. No dashboard code changes. The contract is the promise; the driver is one way to keep it.
The meta-lesson I keep relearning: you don't need the second driver on day one. You need the interface on day one, so adding the second driver later is a new class instead of a refactor. The cost of the seam is one extra file; the cost of not having it is rewriting every call site.
A couple of supporting habits that paid off across the repos:
- Dual identifiers — auto-increment for internal joins, a UUID for anything that crosses a boundary (URLs, API payloads, the wire envelope). Internal id stays fast; public id never leaks row counts.
-
Enums as the vocabulary — roles, severities, and statuses live in PHP enums with
label()/color()helpers, so the UI and the authorization checks read from the same source of truth instead of magic strings. - A versioned wire contract — the payload shape that every client sends is pinned to a schema with a version field, so a richer v1.1 envelope can add fields additively without breaking older senders. Additive-only is the whole discipline: new optional fields are free, renamed or removed fields are a breaking change.
Full architecture write-up in the focused post.
Thread 2 — A tracking-link rewrite that quietly broke signed URLs
Smaller, sharper bug. A self-hosted email open/click tracking package rewrites every <a href> in an outgoing email so clicks route through a redirect first. The redirect encrypts the original URL, then restores it on the way out.
The trap: by the time you read the href out of rendered HTML, Laravel's mail templates have already HTML-escaped it. A signed URL like ?expires=...&signature=... comes through as ?expires=...&signature=.... If you encrypt that string verbatim and later redirect to it, the & survives — and Laravel's signature validation sees a different query string than the one it signed. The link 404s or fails validation, but only for signed URLs (verify-email, signed downloads). Plain links look fine, so it hides.
The fix is one line — decode entities before you capture the URL:
$originalUrl = html_entity_decode($matches[2], ENT_QUOTES | ENT_HTML5);
Focused post has the full rewrite trait and the Pest test that pins it.
Thread 3 — Admin-editable settings without giving up config()
A starter kit got a small but genuinely useful capability: let an admin change the public-registration toggle and the app timezone from a settings screen, persisted in the database — while the rest of the app keeps reading plain config('app.timezone') and config('admin.public_registration').
The pattern is a thin overlay in AppServiceProvider::boot(): read the DB-stored settings, lay them over config() for the request, and (for timezone) call date_default_timezone_set() too, because config() alone doesn't re-apply once the framework has set the default during boot. The neat trick is the registration toggle actually unregisters Fortify's registration feature when it's off, so the route stops existing rather than just being hidden. Focused post walks through the overlay and the boot-order gotcha.
The through-line
All three threads are the same instinct at different scales: decide where the seam goes. Between your platform and its storage. Between a rendered link and the bytes you encrypt. Between a stored setting and the config the app reads. Put the boundary in the right place once, and everything downstream gets to stay simple.
Top comments (0)