Three channels, one question
A plumbing service marketplace collects requests from wherever the customer happens to be: a public booking form on the website, the Android app for repeat customers, and - because plumbing companies onboard in bulk - a CSV import an admin runs from the dashboard. All three answer the same question: who needs a plumber, for what, and where does the request go next. None of them agree on how that request arrives.
The web form is a Livewire component submitting inside an authenticated (or guest) browser session. The Android app is a long-lived native client that has no session to share and expects a bearer token it can hold for months. The CSV import isn't a request at all - it's a batch of rows a company admin drops in, arriving with fields the other two channels never provide, and missing fields they always do.
Why one funnel model, not three
The tempting shortcut is to give each channel its own table and its own downstream logic - a web_inquiries table, an app_inquiries table, an imported_leads table - because each one really does look different at the edge. That shortcut is also how a plumber assignment rule, or a status transition, or a notification trigger ends up implemented three times and drifts three ways the first time one of them changes.
OnlinePlumber normalizes all three into a single inquiries pipeline with one status column, regardless of where the row started:
| Status | Meaning | Who can set it |
|---|---|---|
| new | Captured, not yet looked at | Any channel |
| routed | Matched to a company or plumber | Admin portal |
| assigned | Accepted by a member | Member portal |
| closed | Completed or declined | Member or admin |
Whichever channel a row came from, the pipeline logic - who gets notified, what counts as stale, what an admin sees on the inquiries board - is written once and runs against the status column, not against the channel. The channel only decides how a row gets created, never what happens to it after.
Where the two auth guards earn their keep
Getting a row into that table is a different problem for each client, and this is the one place OnlinePlumber deliberately keeps two systems instead of unifying on one. The web portal - Livewire components rendering inside the same origin as the session cookie - authenticates through Sanctum. The Android app authenticates through Passport, issuing OAuth2 tokens that survive across app restarts with no browser and no cookie jar involved.
// web: Livewire component, same-origin session
Route::middleware('auth:sanctum')->post('/inquiries', [InquiryController::class, 'store']);
// android: bearer token issued once at login, held by the app for months
Route::middleware('auth:api')->post('/api/inquiries', [InquiryController::class, 'store']);The reason this isn't indecision is that the two clients want opposite things from auth. A Livewire portal already lives inside a session - reusing it is free, and Passport's OAuth client model (client IDs, redirect URIs, a token grant dance meant for third-party apps) buys the web portal nothing it doesn't already have. A native Android client has no session to reuse and needs a token that keeps working long after the user closed the app - which is exactly what Sanctum's cookie-backed SPA guard is not built for. Forcing either client onto the other's guard would have meant either standing up OAuth clients for a same-origin form, or bolting a WebView login flow onto the Android app just to get a session cookie a mobile client has no real use for.
The cost is real and worth naming: two token lifecycles to reason about, two revocation paths, and a controller that has to check which guard authenticated the request before it can trust who's making it. That cost is paid once, at the edge, by InquiryController. Everything past that point - status transitions, routing, notifications - reads a plain Inquiry model that has already forgotten which guard let it in.
Notifications fire on status, not on submission
The first version of the notification logic sent an OneSignal push the moment a row was inserted. That was wrong for a reason that only shows up once real traffic hits it: a customer resubmitting a form after a slow network response, or a CSV row that duplicates an existing open inquiry, would page a member before dedup logic had a chance to run.
// fires on the status transition a listener cares about, not on row creation
InquiryStatusChanged::dispatch($inquiry, from: 'new', to: 'routed');
// the OneSignal listener only exists for transitions that need a human to act
class NotifyAssignedMember
{
public function handle(InquiryStatusChanged $event): void
{
if ($event->to !== 'routed') return;
OneSignal::sendToExternalUser($event->inquiry->member_id, ...);
}
}Moving the trigger from "row created" to "status changed to routed" means a duplicate or a bulk-imported row that never gets routed never pages anyone. It also means the CSV import channel, which inserts rows in batches of hundreds, doesn't fire hundreds of pushes on import - only the ones an admin actually routes generate a notification, which is the point where a human is supposed to act.
What the CSV channel costs the schema
Folding a third, non-interactive channel into the same table isn't free either. The Android app supplies a live device location on every request; the web form supplies an address typed by hand; a CSV row supplies neither, and instead carries fields the other two never do, like a pre-assigned company ID from the sheet an admin is importing. The inquiries table ends up with several columns that are nullable and validated conditionally based on the channel that created the row, rather than a schema where every column is guaranteed present. That's the tradeoff for one pipeline instead of three: the routing and notification logic gets to stay simple, and the price is paid once, in validation, at the point where each channel writes the row.
Where it landed
OnlinePlumber is the project I've owned most end to end - close to 600 of its roughly 760 commits - and the inquiries pipeline is the part that took the most rework to get boring. The web portal, the member and company dashboards, and the Android app on the Play Store all still write through their own guard. Nothing downstream of InquiryController knows or needs to know that. The lesson that transfers past plumbing leads: when a system has to accept requests from clients that genuinely differ in how they authenticate, the fix is not picking a winner. It's drawing the boundary at exactly the point where that difference stops mattering.