Implementing a Feature Using Shadow Releases
In Part 1, I talked about the thinking. The constraints. The "what would perfect look like if we started fresh?" conversations. The deliberate decision to shadow-release customisable candidate progression so we could keep making forward progress without setting the platform on fire.
This part is where the rubber meets the road.

The goal stayed simple on paper: customisable stage names and statuses, notifications for every stage and status (that you can switch on and off), and the ability to try all of that on one customer at a time. Without blowing up everyone still living on the old hardcoded path.
Sounds lovely. Now... how do you actually build it?
What "done" looked like in code terms
Before writing a line of the happy-path feature, I needed a definition of done that wasn't "the UI looks nice in Figma".
Done meant:
- No customer-facing stage builder in the first release (that constraint from Part 1 still stood. Stages get set up during onboarding or later ops work)
- One module other code can call without caring whether a customer is on modern or legacy behaviour
- Per-customer enablement with an easy rollback and critically, without adding another traditional feature flag
- The same behaviour whether someone progresses a candidate in the dashboard or an via one of our ATS integrations
That last one was non-negotiable. If migrating a customer onto custom stages left their ATS-driven candidates on a different points or notification path to the UIβ¦ we hadn't shipped a shadow release. We'd shipped a trap π .

Data model first
Hardcoded stage strings had been doing too many jobs for too long π£. They were display names. They were lookup keys for notifications. They were the glue for side-effects. Stretching them further would have been the software equivalent of duct-taping a second storey onto a shed!
So we introduced proper models:
CandidateProgressStage- per customer, step number (0β6), custom name, active flagCandidateProgressStageStatus- belongs to a stage; key, name, outcome (positive/neutral/negative), activeCandidateProgressionAutomationNotification- optional notification config hanging off a status (title, body, push / in-app)
In plain English:
Customer
βββ Stages (step 0β6)
βββ Statuses (outcome + key)
βββ Automation notification (optional)The tables landed ahead of the big behavioural cutover. Schema first. Behaviour second. That ordering mattered. You can't migrate customers onto a system that doesn't have somewhere to live π¬.
The CandidateProgression module
All of this lived in a dedicated module: CandidateProgression.
This was because candidate progression had been scattered across the codebase like confetti after a particularly messy wedding, and I wanted one place future-me (and future teammates) would know to look.
At the front of that module sits a manager. The public API is deliberately boring:
checkCanProgress(...)progressCandidate(...)undoProgression(...)
Callers ask the manager. The manager owns the orchestration: persist the progress row, dispatch the right step handler, fire the event, stay inside a transaction when it should πͺ.
Behind that door: handlers per progression step, and capabilities for the side-effects - points, notifications, audit, live/archived state, starters. Progression itself shouldn't re-implement half the application in every call site.

A controller-shaped usage looks roughly like this:
$check = $progressionManager->checkCanProgress(
$candidate,
$stageId,
$statusId
);
if (!$check->canProgress) {
// tell the user why not β backwards move, unknown stage, etc.
return;
}
$result = $progressionManager->progressCandidate(
$candidate,
$stageId,
$statusId
);In Part 1 I talked about a Manager as an orchestrator sitting in front of business logic. It became the seam everything else hung off and it showed up as a handful of design patterns that made the next change cheaper.
Design patterns that keep future-me sane
I didn't sprinkle patterns to be "clever". They were intentional seams that made shadow release. And whatever comes after it less terrifying.
If you want the textbook versions, Refactoring.Guru explains them better than I will. Here's how they showed up in this work.
Facade (the GoF kind...not the Laravel kind)
CandidateProgressionManager is a Facade in the Gang of Four sense: a class that wraps a noisy subsystem behind a simpler API.
It is not a Laravel Facade.
Laravel Facades are those static proxies into the container - Cache::get(), DB::table(), Illuminate\Support\Facades\β¦. Different animal. Same word. Maximum confusion potential π
!
The progression manager is a normal injectable service that plays the role of a structural Facade. UI code and ATS handlers talk to it. They don't need to know about capability pipelines, handler registration, or which tables just got touched.
Strategy + Factory Method
Modern vs legacy behaviour sits behind the same interface. That's Strategy! Swap the algorithm, keep the callers.
A Factory (practical factory method vibes) chooses which strategy you get, based on whether the customer has been migrated onto custom stages yet. Same idea on the ATS side: progression handlers vs fallback handlers.
Template Method
Abstract handlers own the skeleton of "handle this stage / status change". Concrete step handlers fill in the varying bits. Classic Template Method. Less copy-paste. Fewer "I fixed it here but not over there" moments.
Chain of Responsibility
Side-effects run as an ordered pipeline of capabilities: state -> points -> notifications -> audit -> (sometimes) starters. Middleware can wrap that pipeline the same way (logging was the obvious first guest).
Want a new side-effect later? Add a capability. Don't reopen every controller and webhook handler. That's Chain of Responsibility earning its rent.
Adapter + Observer
Adapter: ATS payloads and legacy name/key stages get translated into the manager's world (UUIDs vs strings; mapped ATS stage -> our step ladder).
Observer (light touch): when stages and statuses get created, observers seed defaults and notification rows so enablement isn't a twenty-step manual checklist every time.

None of this is decoration. It's why custom stages, ATS parity, and rollback could land without a big-bang rewrite.
Building the shadow seam
Here's the design decision that usually surprises people.
There is no feature flag for this.
Not Pennant. Not a CustomerFeatureType. Not a boolean column on the customer saying custom_stages_enabled.
"Are they on the new system?" means "Have we migrated them onto custom stages?"
At runtime that collapses to a boring question: do they have rows in candidate_progress_stages?
private function usesModernProgression(Customer $customer): bool
{
return CandidateProgressStage::where('customer_id', $customer->id)->exists();
}Migrated -> CandidateProgressionManager (DB stages, UUIDs). Not migrated -> LegacyCandidateProgressionManager (fallback hardcoded stages).
Same gate for the dashboard. Same gate for ATS. One question. Everywhere.

We'd already been through older flag-based migrations once. We weren't keen to invent another flag just so we could feel like we were "doing feature flags properly". Migration state is the switch. More on how that flip actually happens in a bit.
Side-effects without rewriting everything
Once the progress row is saved, the handler runs its capability chain.
That's where points get awarded (or not). Notifications get looked up. Prefering the new automation-notification table, but falling back to the older one if needed. Audit entries get written. Candidates move between live and archived based on outcome. Starter dates get set when the step calls for it.
Because those concerns live behind the Facade, our ATS integrations and the UI don't each invent their own half-correct version of "what happens when someone reaches interview successful".
And when something genuinely needs to progress quietly? skipCapabilities is the escape hatch. Use sparingly! πΏ

Making ATS integrations play nice (the scary integration)
This is not a footnote. ATS integrations are how a lot of real candidate progression enters the platform.
Part 1's pain still applied here: progress someone in the UI and a whole set of things happened. Progress them via an ATS webhook andβ¦ you might get a different set. Or a subset. Or a shrug π€·ββοΈ.
The old ATS world had grown vendor-specific processors and plenty of places for behaviour to drift. We'd already lived through earlier migration flags. For this work, the deliberate choice was: no new flag. Migration state instead.
Live application sync now routes through a shared path into the ATS Applications stack, rather than forever forking "what Fountain does" vs "what some other ATS does" for the same conceptual event.
In plain English:
- An ATS webhook lands
- Sync / job picks it up
ApplicationEventHandlerfigures out the customer and candidate- ATS stage name gets mapped via the customer's stage mappings
- Stage handlers (or rejection / status handlers) run
- Those handlers resolve a progression manager for that customer - Factory + Strategy again. And call the same API the UI uses
The shadow-release wiring on the ATS side mirrors the factory: progression-step handlers when the customer has custom stages, fallback handlers when they don't. Same migration-state question. Rejections get their own status-handler path so "rejected in the ATS" still lands on the right stage and outcome, instead of disappearing into the void.
We also invested in boring-but-vital groundwork: recording the update source on progression, and logging when a mapping is missing so a per-customer migration is diagnosable instead of mysteriously "quiet".
Honest scope check, because credibility matters: ATS integrations still map onto our canonical step ladder (reserved stage names -> look up the customer's stage by step_number). It is not yet "arbitrary custom stage graph straight from the ATS". Native integration sync is further along for ID-based mappings. That's fine. Shadow release isn't "finish every possible future". It's "ship the risky bit safely".
Dashboard (and everything else) through the same door
Manual progression resolves the same manager and hits the same capability pipeline.
That's the whole point of the Facade. Once our ATS integrations and the UI share the door, migrating a customer onto custom stages is an ops/data change. Not a second rewrite of progression logic with a sticky note that says "remember to update the webhook path too".
Migrating a customer
Enablement is not a feature flag. Enablement is migration.
For existing customers, that means an Artisan command: app:migrate:candidate-progress-stages.
# One customer β the actual shadow release
php artisan app:migrate:candidate-progress-stages --customer=123
# See what would happen first
php artisan app:migrate:candidate-progress-stages --customer=123 --dry-runWhat it does:
- Creates stages 0β6 for that customer (default names to start with)
- Creates the default statuses for each stage
- Observers seed automation notifications from those statuses
- Remaps related point-tariff fields onto stage-based points where needed so that "turned on" isn't half-broken for rewards
New customers can be seeded on create via observers, so they never need the legacy path at all.
After migration, you can tweak names and notifications via ops tooling / later dashboard management. But the switch was never a flag. It was "do the stage rows exist?"
Rollback, the late-stage tripwire from Part 1, is the mirror image: remove that customer's stage rows, and the factory and the ATS fallback handlers flip back to legacy together. Same question. Same answer. Everywhere.

Why skip adding another customer feature flag? Because the migrated data is the capability. UI, ATS integrations, and the factory all ask the same question. Flag soup is how you end up with three sources of truth and a Slack thread titled "which one is real???"
Wrapping up (for now)
So what did we actually build?
- New models for stages, statuses, and per-status notifications
- A CandidateProgression module with a structural Facade (GoF, not Laravel) at the front
- Patterns as seams - Strategy, Factory, Template Method, Chain of Responsibility, Adapter, Observer - so the next change has somewhere safe to land
- Migration-not-flag enablement: stage rows exist -> modern path; otherwise legacy
- ATS and UI through the same door, so shadow-releasing a customer doesn't invent a second progression universe
- Rollback by undoing the migration data, not hunting for a boolean
Part 1 was about choosing shadow releases as risk mitigation. Part 2 was about making that choice real in a legacy codebase without pretending the ATS integrations don't exist.
Next time I'll give my retrospective take around what worked, what was messy (yes, including ATS integration edge cases), and what I'd do differently if I started again.