Onboarding Process
What is the onboarding process.
If an individual/organization wants to use Emmie's services, they will have to go through this process to make a new organization. The backend part of this process can get complicated and many things can go wrong. This page does not contain any information about the frontend.
Some clarifications:
- Customer. This is the organization that will be created.
$customer->namerefers to the name of the organization, not the user who is going through the onboarding process. - Main database or central DB = emmie's database. The tables to look out for are:
onboardingonboarding_usersfailed_jobsdomainscustomers
- emmie s3 bucket. We need this bucket to exist to use
customer_logo. Minio needs to be running. Checklocalhost:9001and look for a bucket called emmie. If it doesn't exist, make one.
WARNING
It is important that you manually test the onboarding process if you make any changes, especially before creating/updating a PR. There is currently no e2e test for the full onboarding process and the integration test will only get us so far. Things to look out for:
- Can I enter into the new organization? (no might mean the new customer does not contain any data).
- When loading the dashboard, are there any errors? (the number of errors should be zero)
- Are all emails being sent? (
CustomerSetupfor Admin who created the org.UserInvitefor every other user added during the process).Maildevshould be running. Probably onhttp://localhost:1080. If you receive any emails that have a broken image, you might not have an s3 bucket called just "emmie". - What data is left behind in the main database? (If onboarding still contains data, something went wrong. Being able to reach the dashboard doesn't mean some non-critical failures didn't happen)
Major Classes.
OnboardingPayloadDto. The single source of truth about what onboarding data must look like. The typed contract for whatSetupCustomerActionreads. Built viaOnboardingPayloadDto::fromCustomer(Customer $customer), which parses the rawCustomer::onboardingpayload into this DTO's typed properties (nullable per-section leaf DTOs, plus a non-nullableOnboardingBirthdayEmailDto).SetupCustomerActionis the sole consumer that converts the raw attribute into this typed shape — see "Adding, changing, or removing onboarding keys" below.OnboardingController. Deals with a single entry in theonboardingtable in the Main Database. Also starts theCreateCustomerJob.CreateCustomer(Job). A thin async queue wrapper —handle()delegates straight toProvisionCustomerAction,failed()straight toRollbackCustomerProvisioningAction. No orchestration logic lives on the Job itself.ProvisionCustomerAction. In charge of turning onboarding data into a functional customer: creates the domain, tenant database, runs migrations, creates the S3 bucket, runsSetupCustomer— with a sanity check after every major step. Highly paranoid.RollbackCustomerProvisioningAction. Rolls back a failed provisioning attempt based ononboarding_step: deletes whatever was created so far (tenant database, S3 bucket, logo, domain, customer record) and deploys a last-resort orphan tripwire.OnboardCustomerAction. Decides whether a customer needs setup, orchestratesSetupCustomerAction, sends invite/setup emails, and marks the customer as onboarded.SetupCustomerAction. If a new database has been created, migrations were successful and thes3 bucketwas created (CreateCustomer Job), this action fills the new database with information from the onboarding process usingOnboardingPayloadDto. Quite paranoid, less destructive thanCreateCustomer.SeedOnboardingDefaultsAction. Composition root for onboarding data that is unconditionally auto-seeded rather than driven by wizard-payload input — gains one more leaf seed action per onboarding-revamp gutting ticket (mentor types as of EMMIE-0416, care types as of EMMIE-0417, care legislations as of EMMIE-0420, interested e-mail templates as of EMMIE-0451, terminology as of EMMIE-0430; more may follow). Injected as a single dependency intoSetupCustomerAction, called unconditionally at the very start of its transaction. No try/catch — a thrown exception aborts the whole customer-provisioning transaction, which is correct for data every tenant must always have. Currently composesSeedDefaultMentorTypesAction,SeedDefaultCareTypesAction,SeedDefaultCareLegislationsAction,SeedDefaultInterestedEmailTemplatesAction, andSeedDefaultTerminologyAction.
Actual process steps.
These steps assume all data entered by the 'user' is correct/valid. We do not deal with frontend here.
On the OnboardingController
Every step method below is a thin controller action: it validates via a FormRequest, converts to a DTO via toDto(), and delegates the actual write to a dedicated Action injected into the method. The controller itself contains no onboarding-write logic — see the corresponding Action for what actually happens to the onboarding record.
register()withCreateOnboardingRegistrationAction. Creates a new entry in theonboardingtable in the MAIN DATABASE withadmin_emailand HASHEDadmin_password. Returns the token from this entry.admin()withUpdateOnboardingAdminAction. Updates onboarding entry withadmin_first_name,admin_last_nameandadmin_phone. Returns onlyOkResponse.organizationName()withUpdateOnboardingOrganizationNameAction. Updates onboarding entry withname,kvk_numberandlogo(optional). Returns onlyOkResponse.WARNING
logois stored in emmie's own bucket. when runningphp artisan migrate:fresh --seedfor local development, this can silently fail. Checklocalhost:9001to see if there is a bucket called "emmie".address()withUpdateOnboardingAddressAction. Updates onboarding entry with:address_zipcode,address_number,address_number_addition(optional),address_street&address_city. Returns onlyOkResponse.auth()withUpdateOnboardingAuthAction. Optional. Updates onboarding entry withtwo_step_verificationandmax_session_duration. Only returnsOkResponse.warning()withUpdateOnboardingWarningAction. All optional. Updates onboarding entry with:evaluation_interval,financing_start_interval,warning_expiring_financing&missing_registration_interval. Only returnsOkResponse.birthday()withUpdateOnboardingBirthdayAction. Optional. Updates onboarding entry with:birthday_send_email,birthday_email_subject&birthday_email_content. Only returnsOkResponse.mdo()withUpdateOnboardingMdoAction. Optional? Updates onboarding entry withmdo_enabledandmdo_interval. Only returnsOkResponse.
EMMIE-0416
Mentor types no longer have a wizard step here — mentorsType()/UpdateOnboardingMentorsTypeAction (formerly step 6) were removed. Mentor types are now unconditionally auto-seeded by SeedDefaultMentorTypesAction during SetupCustomerAction, regardless of any onboarding payload. The Request/Dto/Action classes that used to serve this step were kept unwired (marked TODO(EMMIE-0528) dead code) through EMMIE-0417, then deleted outright in EMMIE-0420 alongside the rest of the care() step's removal — see that ticket for the "mark, soak one cycle, delete" convention this exemplifies.
EMMIE-0417 / EMMIE-0420
The care() step (formerly step 5) is gone entirely as of EMMIE-0420. care_types was removed from its payload first (EMMIE-0417) — unconditionally auto-seeded (both existing provided_care_types rows activated) by SeedDefaultCareTypesAction — leaving only care_legislations. Required because a tenant with zero active care types hits an infinite redirect loop in Planning — see docs/onboarding/BreakingOnboarding.md Round 2.5. Once care_legislations was the only field left, EMMIE-0420 auto-seeded it too (all 5 OnboardingCareLegislationsEnum cases, via SeedDefaultCareLegislationsAction) and removed the step's entire backend surface — OnboardingCareRequest, OnboardingCareDto, UpdateOnboardingCareAction, the SaveOnboardingTypesAction/Dto/Interface trio, the care() controller method, and the onboarding-care route — all deleted outright, not marked.
EMMIE-0422
The users() step (formerly step 5, "invite colleagues") is gone entirely — with no auto-seed replacement, unlike the mentor-types/care-types/care-legislations tickets above. Colleague invites aren't something every tenant needs by default, so this is a pure removal, not a gutting-and-replacing. Deleted outright: OnboardingController::users() + the onboarding-users route, ReplaceOnboardingUsersAction (the wizard-side write path), OnboardingUsersRequest, both OnboardingUsersDto/OnboardingUserDto pairs (Input + Model namespaces) and their interfaces, and CreateOnboardingUsersAction (the provisioning-side action, formerly SetupCustomerAction step 5). OnboardingPayloadDto::$users and SetupCustomerResultDto::$created_users were removed, along with OnboardCustomerAction's per-colleague UserInvite loop — eliminating the async-email problem the ticket was named for. CustomerSetup (sent to the org creator's admin_email) and the CreateCustomerAdmin fallback job are untouched. The ticket's stated premise — that seeders create colleagues and send UserInvite emails during migrate:fresh --seed — did not hold in the current codebase (verified exhaustively across admin/user/client actors); no seeder or factory change was needed.
Not deleted: the onboarding_users table and the OnboardingUser model. These are kept, marked dead, exactly like care_types/care_legislations/mentor_types above — pending the epic-wide EMMIE-0528 central-model column-drop. Onboarding::users(): HasMany stays wired, because CleanupOnboardingAction still calls it to delete any pre-existing legacy onboarding_users rows on successful cleanup (now a legacy-only code path, commented as such).
EMMIE-0430
The terminology() step (formerly step 5) is gone entirely. Client terminology is no longer user-chosen during onboarding — every new tenant now gets Dutch defaults (cliënt/cliënten) unconditionally auto-seeded by SeedDefaultTerminologyAction, the 5th leaf in SeedOnboardingDefaultsAction. This closes a landmine: once the step is gutted, a never-seeded terminology row had no recovery path from the UI (TerminologyController::update() is a pure UPDATE, never an INSERT). Unlike the mentor-types/care-types/care-legislations tickets above, terminology's failure tier changed from non-critical (warn-and-continue) to critical (throws) — the frontend terminology-edit UI doesn't degrade gracefully on a missing row. Deleted outright, not marked for later: SaveTerminologyAction/Dto/Interface, OnboardingTerminologyRequest, UpdateOnboardingTerminologyAction, OnboardingTerminologyDto, the terminology() controller method, and the onboarding-terminology route. Onboarding::terminology_singular/ terminology_plural join the care_types/care_legislations/mentor_types/onboarding_users dead-column group above, pending EMMIE-0528.
EMMIE-0701 — the seed also sets the VECOZO coupling
EMMIE-0420 seeded name only, leaving vecozo_financing_type NULL on all five rows. A tenant in that state cannot produce a 323 at all, and there is no setup step anywhere that would say so. SeedDefaultCareLegislationsAction now also writes the wet — OnboardingCareLegislationsEnum:: vecozoFinancingType(). Since EMMIE-0779 that is four of the five: WMO, Jeugdwet, Wlz and Zvw. The coupling names the wet, not a 323 — Wlz and Zvw still cannot be declared, and every reader on the declaration path asks iStandaardSchema() for that — but the contractlade offers a box per coupled wet, so an uncoupled Wlz could not be put on a contract. The Participatiewet stays NULL because VecozoFinancingTypeEnum has no case for it. Existing tenants get the same two couplings from customer/2026_09_06_000000_couple_wlz_and_zvw_care_legislations.php.
Two consequences for anyone touching this Action:
- Its idempotency guard weighs the coupling, not just the name set. Matching names with NULL couplings take a repair-in-place branch (
UPDATEon the NULL rows). They must never fall through to the force-delete-and-reseed branch, which is only justified on a brand-new tenant database — existing financings point at these rows by id. - Neither the Action nor the paired backfill migration (
customer/2026_08_18_000000_backfill_care_legislation_vecozo_financing_type.php) ever overwrites a coupling that is already set, and neither maps a name it does not recognise. Tenants provisioned before EMMIE-0420 typed their legislation names by hand, and a guessed wet puts the wrong digit into the BerichtIdentificatie sent to Vecozo.
Sequencing rule — on a tenant provisioned before the financieringsroute epic, fill in the routes BEFORE coupling any zorgwet row
Couple a zorgwet row only when no financiering under it still has a NULL financing_route — whatever the row is named. Tenants provisioned before the epic typed the betaalroute into the name, so a name is a claim about the routes beneath it, not a fact.
DetermineDeclarationStatusAction asks the route first, but routeStatus() answers null both for an absent route (invariant I6) and for ZIN — the one route that does go through iWmo/iJw — and null falls through to the wet's vecozo_financing_type. The route settles the channel only for onderaanneming, pgb, UWV, particulier and overig; coupling a wet therefore moves every NULL-route and every ZIN registration under the row onto that wet's channel the same minute — the intended effect for ZIN, an unreviewed one for NULL. For a wet with a 323 (Wmo, Jeugdwet) that is the gemeente channel — te declareren where nothing has been submitted for it, declared or partially declared where something has (the action reads submitted declarations after the wet fallback). For Wlz and Zvw, which have no iStandaard schema, the row reads as another channel before any declaration is counted.
- Set the route on the zorgwet row itself, in the same save as the wet. The row carries both (
care_legislations.financing_routeandvecozo_financing_type), andApplyCareLegislationRouteActionwrites the row's route onto every financiering under it whose route is absent or differs, and onto every contract under it for an organisation route — but only when the row's route is set: a row saved with a wet and Nog instellen writes nothing down, and that is the state this rule guards. The contractlade does not do this —UpdateContractActionwritescontracts.financing_routeand never the financings' column, and the EMMIE-0783 backfill that copied contract routes onto financings ran once, at release. A financiering edit writes the column for that one financiering only. - Count, then couple.
financings:report-route-backfillprints per klantomgeving how many financieringen still have no route; a tenant total of zero clears every row at once. No shipped report breaks that total down per zorgwet row, so on a nonzero total the count under the row has to be read against the database before that row is coupled. - Rows EMMIE-0701 already coupled by exact name — the Wmo and Jeugdwet rows, and only those (its backfill migration maps no other name) are a live population to read first, not a sequencing question: their NULL-route financieringen already sit under a coupled wet, and a coupled wet does not make them declarable —
Generate323FileActionrefuses a debiteur without gemeentecode.
No code guard enforces step 2 (ruled 2026-09-02 on EMMIE-0785: a guard would need the row to already know its route, which is step 1). This block is the guard.
user accepts terms and conditions
Store(). The one step with no dedicated Action — the array assembly +Customer::create()+ job dispatch stays in the controller (see D6 inEMMIE-0414; extracting this provisioning orchestrator into its own Action is out of scope for that ticket).- Fills
$customerOnboardingarray with data fromonboardingtable per category. (Used to also includeonboarding_usersfor theusersstep — removed in EMMIE-0422.) - We create a new entry in the
customerstable in the Main Database. Does NOT trigger events.INFO
$customerOnboardingends up as one key inside the realdataJSON column oncustomers(alongside other virtual attributes, e.g.intake_price).onboardingitself is not its own column and has no dedicated Eloquent JSON cast —Customeruses Stancl Tenancy'sVirtualColumntrait, which reads/writes it in and out ofdatavia magic getters/setters. Don't "fix" this later by adding acasts()entry foronboardingspecifically. - '
onboarding_step' is set toDATABASE_SETUP. - We write
$customer->idback to theonboardingrecord ascustomer_id. This links the token to the new customer without exposing a sequential ID in the URL. - we dispatch the
CreateCustomerJob. We do NOT wait until it is finished. - Returns $customer->id.
- Fills
in regular intervals, frontend asks backend for update on onboarding_step with getOnboardingStep(token)
getOnboardingStep(token) resolves the current step without exposing a sequential customer ID in the URL:
- Token not found → returns
FINISHED(customer already live, onboarding record cleaned up). - Token found but
customer_idis null → returnsERROR(should not happen in normal flow). - Token found,
customer_idset, but customer record not found → returnsERROR(customer was deleted, e.g. byfailed()). - Otherwise → returns the customer's current
onboarding_step.
Create Customer Job → ProvisionCustomerAction
CreateCustomer (Job) is a thin async wrapper. Constructed with:
$customer. The organization we are trying to create$onboarding. If provisioning is successful, we use this for cleanup
The constructor also resolves config()->string('tenancy.tenant_domain') once into a $tenantDomain property. This is passed into both ProvisionCustomerAction::execute() and RollbackCustomerProvisioningAction::execute() as a parameter, rather than either Action injecting Illuminate\Contracts\Config\Repository itself — Actions are capped at 5 constructor dependencies (SRP, ActionTest.php), and ProvisionCustomerAction already has 5 (the verify/cleanup actions below) without it. Jobs aren't subject to that cap or to Rector's ban on raw config() calls in Actions (ArgumentFuncCallToMethodCallRector skips app/Jobs/*.php), so resolving it here is the correct place, not a workaround.
handle(ProvisionCustomerAction $provisionCustomer) delegates immediately: $provisionCustomer->execute($this->customer, $this->onboarding, $this->tenantDomain). All the paranoid step-by-step orchestration below lives in ProvisionCustomerAction, not the Job — this keeps the Job itself queue-transport logic only (so it stays testable via a plain Unit test asserting delegation), and makes the actual orchestration independently testable without touching the queue at all.
ProvisionCustomerAction is paranoid. After every major database action, we do a sanity check. All major steps after CreateDatabase require the previous step to be successfully completed. If anything goes wrong, the exception propagates back out of execute(), out of handle(), and the queue worker marks the job failed — CreateCustomer::failed() then delegates to RollbackCustomerProvisioningAction::execute($this->customer, $this->tenantDomain), which deletes everything created so far and checks if we left any orphans behind.
It injects the 5 verify/cleanup actions below so we can mock them for the integration test: VerifyDatabaseCreationAction, VerifyDatabaseMigrationAction, VerifyBucketCreationAction, VerifyCustomerOnboardingAction, CleanupOnboardingAction.
execute(Customer $customer, Onboarding $onboarding, string $tenantDomain):
- We make
$domainby taking the name of the organization ($customer->name). - We make a new entry in the
domainstable in the Main Database. - we
saveQuitelyto storetenancy_db_nameon this customer's entry in the main database. This is inside the data column.
INFO
tenancy_db_name is required for the CreateDatabase Job. It extracts it automatically. tenancy_db_name will be something like customer2.
CreateDatabaseJob. We wait until it is finished.- Sanity Check.
VerifyDatabaseCreationActionchecks whether a new database is created. Queriesinformation_schemato confirm the new database actually exists on the MySQL server, filtered by the explicit database name (not the ambient connection's current database). If it throws,execute()stops here and the exception propagates tofailed(). - Passed the sanity check. We set the customer's
onboarding_steptoDATABASE_MIGRATION. - MigrateDatabase Job. We wait until it is finished. Both
MigrateDatabaseandCreateAwsBucketrun inside a single tenant context closure ($customer->run(...)). - Sanity Check.
VerifyDatabaseMigrationActioncounts tables in the new tenant database, filtered by the explicit database name passed in (notDATABASE()— the connection's ambient current database, which can be stale by this point in the pipeline). If zero tables exist, migrations failed. If it throws,execute()stops here. CreateAwsBucketJob. We wait until it is finished.- Sanity Check.
VerifyBucketCreationActionverifies thatCreateAwsBucketis fully completed - checks that the bucket field was persisted to the customer's DB record. - Passed 2 sanity checks. We set the customer's
onboarding_steptoCUSTOMER_SETUP. SetupCustomerJob. We wait until it is finished.
This is where it becomes fun. We have many nested actions.
On the SetupCustomer Job, we use OnboardCustomerAction to check whether the $customer needs a setup. We are creating a new customer so yes. If OnboardCustomerAction returns false — either because the customer is already onboarded, or because the customer has no onboarding data (onboarding is null) — we dispatch a CreateCustomerAdmin Job as a fallback to ensure the admin user still gets created.
On OnboardCustomerAction, SetupCustomerAction is injected in the constructor and called in execute(Customer $customer, OnboardingPayloadDto $payload) — OnboardCustomerAction builds the DTO itself via OnboardingPayloadDto::fromCustomer($customer) before calling it (see "Major Classes" above).
SetupCustomerAction
Delegates leaf actions to fill new database with onboarding data. It is quite paranoid but it does not react as destructive as the CreateCustomer Job. It can deal with non-critical errors more gracefully and will log those. ANY non-critical warnings will cause onboarding data to persist in the Main Database. Most steps check the corresponding OnboardingPayloadDto property against null to skip gracefully if that section was not provided. Three steps are NOT gated this way, and run unconditionally regardless of payload shape: seedOnboardingDefaults (never was gated — terminology moved into this unconditional group as of EMMIE-0430), settings (EMMIE-0923 — previously gated, made unconditional because a missing row is a hard crash on login), and birthdayEmail (defaults resolved inside saveBirthdayEmail()).
Transaction safety (EMMIE-0470)
Steps 1-6 below now run inside a single database transaction ($databaseManager->connection()->transaction(...)), opened inside the $customer->run() closure and wrapping the entire closure body. If any step throws (critical failures: seedOnboardingDefaults, settings (EMMIE-0923), admin, or an unexpected exception from createDefaultLocation), every write in the same run rolls back together — including steps that already "succeeded" earlier in the same run. Non-critical steps that only log a warning (don't throw) still commit normally at the end.
DatabaseManager is method-injected on execute() — a deliberate, documented exception to ADR-0021 ("always use ConnectionInterface, not DatabaseManager, in Actions"). This Action performs its own tenant-context switch ($customer->run()) inside execute()'s body; a constructor-injected ConnectionInterface would resolve before that switch and silently open a transaction against the wrong (central) database — Laravel resolves the whole constructor dependency graph when SetupCustomer Job's handle() is invoked, which happens before the switch. See EMMIE-0470 (D0) for the full reasoning, including why this is scoped as a one-off tied to the tenancy system's own planned deprecation, not a precedent for other Actions.
CreateAdminUserAction (step 3 below) no longer carries its own ConnectionInterface/->transaction() wrapping — it previously did, but that wrapping had the same timing bug (resolved before the tenant switch, so it silently transacted the central connection while its real writes hit the tenant connection). Removing it isn't a regression: its writes are now correctly covered by SetupCustomerAction's outer transaction instead. Its former sibling here, CreateOnboardingUsersAction, was deleted outright in EMMIE-0422 along with the users step below. SeedOnboardingDefaultsAction/SeedDefaultMentorTypesAction/SeedDefaultCareTypesAction/SeedDefaultCareLegislationsAction/SeedDefaultInterestedEmailTemplatesAction/SeedDefaultTerminologyAction (step 1) follow the same pattern — no transaction of their own, exclusively called from within this one.
$databaseManager is threaded down from SetupCustomer Job's handle() (a framework entry point, auto-injected) through OnboardCustomerAction::execute() (which forwards it, unchanged) to SetupCustomerAction::execute().
seedOnboardingDefaults. Critical. UsesSeedOnboardingDefaultsAction, called unconditionally — not gated on anyOnboardingPayloadDtoproperty. Currently seeds the 4 fixed mentor types viaSeedDefaultMentorTypesAction(EMMIE-0416), activates both fixed care types viaSeedDefaultCareTypesAction(EMMIE-0417), seeds all 5 fixed care legislations viaSeedDefaultCareLegislationsAction(EMMIE-0420), seeds the interested-client e-mail templates viaSeedDefaultInterestedEmailTemplatesAction(EMMIE-0451), and seeds the Dutch client-terminology row viaSeedDefaultTerminologyAction(EMMIE-0430); more onboarding-revamp gutting tickets may add more leaf actions here over time.CreateAdminUserAction(step 3) requires at least one mentor type to exist, so this must run first — it does, since it's step 1. The other leaves carry no such downstream ordering dependency.settings. Critical (EMMIE-0923). UsesSaveSettingsAction, called unconditionally — not gated on anyOnboardingPayloadDtoproperty, matchingseedOnboardingDefaultsabove. Falls back to an all-nullSaveSettingsDtowhen$payload->settingsis absent;SaveSettingsActionalready resolves every field to a sensible default via??. Sanity check for persistence; if the row is missing afterward, throws instead of logging a warning — a missingSettingsrow causes a hard crash on login (No query results for model [Settings]) for every user of that tenant, perdocs/onboarding/BreakingOnboarding.md. Seedocs/onboarding/MigrationsVsActions.md("settings is a landmine") for the full investigation.admin. Critical. Cannot enter platform without this admin. Organisation would become unaccessable. If it fails, it throws back to the CreateCustomer Job which will triggerfailed().createDefaultLocation. Creates a default location named 'Hoofdlocatie' in the tenant database using the customer's address from the central DB. Skipped silently if a location already exists. Not wrapped in a try/catch — an unexpected failure here throws back to theCreateCustomerJob and triggersfailed().saveBirthdayEmail. Method contains backup for email because frontend doesn't know what a valid email looks like. If this fails, logs a warning. Considered Non-critical, platform works without.saveIntroductoryMeetingEmail. None of this data can be edited by the user during the onboarding process, was left as a template. Considered non-critical, warnings are logged.- If there were no critical failures, we return
SetupCustomerResultDto.
EMMIE-0422
The users step (formerly step 5 here, provisioning-side) is gone — CreateOnboardingUsersAction was deleted outright, along with SetupCustomerResultDto::$created_users. No colleague is ever created during onboarding provisioning anymore.
Back to OnboardCustomerAction.
- we use the
$resultto invite the user who made the organization withCustomerSetup, sent to$customer->admin_email. Failing this is very annoying and should be considered higher than non-critical but should not cause CreateCustomer to trigger failed. Currently, we are not dealing with any failures here. This email is send IMMEDIATELY (sync).EMMIE-0422
This step used to also invite every colleague added via the
usersonboarding step, queuing aUserInviteemail per created user (async). That loop — and thecreated_usersfield onSetupCustomerResultDtothat fed it — was removed. No colleague is ever created or invited during onboarding provisioning anymore. The standalone admin "add user" flow (outside onboarding,CreateUserAction→SendUserInviteAction) is unaffected and still sendsUserInvite. - we set
onboardedto true for this customer in the customers table in the Main Database. If there were no warnings, we also setCustomer::onboarding(a virtual attribute backed by thedatacolumn, see thestore()info box above) to null. If there are warnings, we don't — this is a deliberate exception whereOnboardCustomerActionkeeps touching the raw value directly rather than going throughOnboardingPayloadDto(see D4 inEMMIE-0414).
Back to the SetupCustomer Job.
Back to ProvisionCustomerAction.
Sanity Check. We verify that the customer has actually been onboarded. If it has not,
execute()stops here and the exception propagates tofailed().We passed the last sanity check. We update
onboarding_steptoFINISHED.cleanupOnboardingAction. We try to deleteonboarding_usersfirst, then theonboardingrecord. Because the FK constraint ononboarding_users.onboarding_idprevents deleting the parent while children exist, the two deletes are not independent — if users can't be deleted, the record can't be deleted either. Both are wrapped in a single try/catch. Non-critical, a single warning is logged on failure.EMMIE-0422
Since the
users()step was removed entirely (see above), no newonboarding_usersrow is ever created again. This delete-children-before-parent call now only ever matters for rows that predate this ticket — it's kept, commented as legacy-only, because the FK constraint is still real (onboarding_usersisn't dropped, pending EMMIE-0528).ProvisionCustomerAction::execute()is now complete. We do not return anything. Frontend has been asking backend for updates on onboarding_step.
Back to the OnboardingController.
- Once
ProvisionCustomerActionhas done its job, it has updated theonboarding_steptoFINISHED. Frontend is now happy and ask for the domain link. getLoginLinkwill return the new domain link. Frontend automatically redirects.
In short
A generic overview of the process (ignoring failures and cleanup):
OnboardingController
└─▶ CreateCustomer Job (thin async wrapper)
└─▶ ProvisionCustomerAction
├─▶ CreateDatabase
├─▶ MigrateDatabase
├─▶ CreateAwsBucket
└─▶ SetupCustomer Job
└─▶ OnboardCustomerAction
├─▶ SetupCustomerAction
└─▶ SendInviteEmailsOn failure, CreateCustomer::failed() delegates to RollbackCustomerProvisioningAction (see "Rollback logic" below).
Quirks
Inside SetupCustomerAction, mentor types are unconditionally auto-seeded (EMMIE-0416, seedOnboardingDefaults — step 1, always critical, not payload-gated) because admins are automatically assigned the first mentor type (caseload shenanigans). CreateAdminUserAction contains $mentorTypeId = $this->mentorType->newQuery()->firstOrFail()->id;, which is why seedOnboardingDefaults must run before admin (step 3) in the same transaction — SeedDefaultMentorTypesAction itself also carries a paranoid post-write count-check (MentorType::count() !== 4) since Eloquent::save() can return false without throwing, which would otherwise silently under-seed with no error anywhere downstream.
In register() with CreateOnboardingRegistrationAction, we hash admin_password. After that, we copy this HASHED PASSWORD inside CreateAdminUserAction.
DANGER
DO NOT HASH admin_password AGAIN INSIDE CreateAdminUserAction. IT IS ALREADY HASHED (by CreateOnboardingRegistrationAction). HASHING IT AGAIN WILL DOUBLE HASH IT. CreateAdminUserAction::execute() already carries an inline comment guarding this ("Password is expected to arrive pre-hashed") — if you're touching that method, read it.
getOnboardingStep() takes an onboarding token, not a sequential customer ID. This prevents a malicious actor from enumerating customer IDs to track their competitors' onboarding progress. customer_id is written to the onboarding record in store() after the customer is created so the token can be resolved to a step.
If we fail, we try to do so correctly.
ProvisionCustomerAction is highly paranoid. After each job, it attempts to check if it did so correctly. CreateCustomer only tries 1 time (#[Tries(1)]) because it is unlikely to succeed after it tried once. When CreateCustomer::failed() delegates to RollbackCustomerProvisioningAction, we rely somewhat heavily on OnboardingStepEnum. We do NOT want to delete any database we don't want to delete. We also do not want any orphaned databases.
deployOrphanTripwire() (a private method on RollbackCustomerProvisioningAction) checks for any orphaned resources after a failed onboarding cleanup. It is a tripwire because it only attempts to warn us about nearby orphans, it does not delete them.
When RollbackCustomerProvisioningAction runs, all onboarding and onboarding_users data is preserved. This is intentional. We can use it to manually help a customer get their onboarding done. For development, it is useful to look at the corpses of a failed job. The failed_jobs table might also be interesting to look at.
Rollback logic
All steps are wrapped individually so a failure in one does not prevent the others from running. Not every rollback step is gated by onboarding_step the same way — some are step-conditional, others run unconditionally for any non-FINISHED failure:
- Tenant database (step-conditional): skipped only if
onboarding_step === DATABASE_SETUP(nothing to delete yet). Otherwise attemptsDeleteDatabase. - S3 bucket (step-conditional): attempted only if
onboarding_stepisDATABASE_MIGRATIONorCUSTOMER_SETUP— the bucket is created during theDATABASE_MIGRATIONstep, before the step advances toCUSTOMER_SETUP, so both must be covered.DeleteAwsBucketitself logs a warning (does not throw) when the bucket doesn't exist, so a missing bucket is not treated as a rollback failure here. - Customer logo (unconditional):
DeleteCustomerLogoActionruns regardless ofonboarding_step. Failure logs a warning; does not affect the rest of the rollback. - Domain + customer record (unconditional): always attempted last. If this fails, we set
onboarding_steptoERRORas a last-resort marker (the orphan tripwire is the actual safety net at that point). deployOrphanTripwirealways fires at the end, regardless of what failed above.
The Big Tests
There are 5 big tests that check if all works well — note that SetupCustomerActionTest exists twice, in two different test suites with different concerns. Easy to forget one exists (it happened to us); make sure Claude knows about both when touching either.
CustomerOnboardingTest(tests/Integration/onboarding/): INTEGRATION test forProvisionCustomerAction's andRollbackCustomerProvisioningAction's orchestration and rollback. Normal unit test things do not work here. Also contains theOnboardingPayloadDto::fromCustomer()shape-compatibility test (see "Adding, changing, or removing onboarding keys" below) in the samedescribe()block, ahead of the pipeline tests in the->depends()chain — a DTO-shape regression fails fast instead of silently running an expensive pipeline built on a broken assumption.CreateCustomerTest(tests/Unit/app/Jobs/): UNIT test for theCreateCustomerjob itself. Only asserts delegation —handle()callsProvisionCustomerAction::execute(),failed()callsRollbackCustomerProvisioningAction::execute(). No real infrastructure; both Actions are mocked.RollbackCustomerProvisioningActionTest(tests/Unit/app/Actions/Model/CustomerOnboarding/): UNIT test with real coverage value: it exercises thecatch (Throwable) {}swallow branches aroundDeleteDatabase/DeleteAwsBucket, anddeployOrphanTripwire()'s three warning-firing branches — none of which the Integration test can reach, because real infrastructure never fails on demand there (cleanup always succeeds when attempted). UsesModel::setConnectionResolver()to make the staticCustomer::find()call insidedeployOrphanTripwire()resolve without a real database connection.SetupCustomerActionTest(tests/Unit/app/Actions/Model/CustomerOnboarding/): UNIT test. ConstructsOnboardingPayloadDtodirectly (noCustomermock foronboardingneeded) and mocks every injected sub-action.SetupCustomerActionTest(tests/Integration/app/Actions/Model/CustomerOnboarding/): INTEGRATION test. Seeds the realCustomer::onboardingvirtual attribute with a raw array, builds the DTO viaOnboardingPayloadDto::fromCustomer($this->tenant), and asserts against the real tenant database (realProvidedCareType/CareLegislation/Terminology/User/Emailrecords — no Mockery). Deliberately does NOT assert granularMentorTyperow content (EMMIE-0416) — that belongs toSeedOnboardingDefaultsActionTestbelow, not here; this file only confirms ordering (admin ends up with exactly 1 mentor type attached).
Not listed above: ProvisionCustomerActionTest (tests/Unit/app/Actions/Model/CustomerOnboarding/) also exists, but only to satisfy the 100%-Unit-coverage-for-Actions rule — this Action has no branches (no if/catch), so its single happy-path test verifies nothing the Integration test doesn't already prove more realistically. Not a "big test" in the sense above; a compliance formality.
Also not one of "the 5", but worth knowing about as the onboarding-revamp gutting phase progresses: SeedOnboardingDefaultsActionTest (tests/Integration/app/Actions/Model/CustomerOnboarding/) is the real-DB test for the SeedOnboardingDefaultsAction composition root (see "Major Classes" above). Its root/happy-path test is deliberately named generically ('should seed all onboarding defaults', not '...mentor types') — every future gutting ticket extends that same test's assertions to cover its own newly-seeded data, rather than adding a parallel happy-path test, and chains its own failure-path test off it via ->depends() (same fail-fast convention as CustomerOnboardingTest). SetupCustomerActionTest (both Unit and Integration) only needs the light touch of confirming SeedOnboardingDefaultsAction::execute() is called — real seeded-data correctness lives entirely in this file, not there.
CustomerOnboarding Integration Test
CustomerOnboardingTest contains two distinct concerns in one file, in one describe() block (see "The Big Tests" above for why they're paired via ->depends()):
- The
OnboardingPayloadDto::fromCustomer()shape-compatibility test — lightweight, no MinIO, no mocks. Just seedsCustomer::onboardingwith a raw array and asserts the parsed DTO matches. - The provisioning pipeline tests —
ProvisionCustomerAction's andRollbackCustomerProvisioningAction's orchestration and rollback logic, called directly (not via the Job/queue). This does NOT include SetupCustomerAction (which is independently tested). Each of these tests runs REAL infrastructure (MySQL + MinIO) and mocks only the 5 sanity/cleanup actions injected intoProvisionCustomerActionto simulate failure at a specific point in the pipeline. All sanity checks in the pipeline are tested independently.
First we try to complete the happy path. If this fails, all other tests don't matter so we use depends().
To make it work in the first place, some exceptions are required:
Roleis a central DB model. We NEED it to exist in the main database before everything else. HenceseedBeheerderRole().@allow-mockery. Normally, the use of mockery is banned by the architecture test. We need to mock the 5 sanity/cleanup actions to simulate failures are specific points during the pipeline.Rectorautomatically messes upwithTenancyTestCase.
Why might the integration test be failing?
- The wind is blowing from the wrong the direction.
- You might get warnings about not being able to delete s3 buckets that don't exist. That is fine.
- Has Rector secretly changed some stuff? It shouldn't but you never know.
- Running the integration locally =/= CI. If you have ran
php artisan migrate:fresh --seedlocally, it will seed your database after the migration. CI has multiple workers that delegate tasks. They will also refresh the database which can cause problems if you expect the data to there. - Did you add/remove/change a key somewhere
OnboardingPayloadDto::fromCustomer()relies on, without updating the leaf DTO it builds or the shape-compatibility test in this file? See "Adding, changing, or removing onboarding keys" below.
WARNING
Without a backend/.env.testing, running this (or any) Integration test locally resets your regular dev database. phpunit.xml forces APP_ENV=testing, and Laravel loads .env.testing for that environment if it exists — copy backend/.env.testing.example to backend/.env.testing (it points DB_DATABASE at an isolated test_emmie database) so Integration tests don't touch your dev data at all. If you skip this, Laravel silently falls back to your normal .env, so Integration tests point at the same central emmie database your dev server uses. The first Integration test run in a fresh PHP process then triggers RefreshDatabase's migrate:fresh + a reseed via TenancySeeder, which does not call RoleSeeder/PermissionSeeder. This silently wipes roles/permissions (and anything else only db:seed populates) from your actual dev environment — you will likely see permission errors ("Je hebt geen toegang...") on the dashboard afterward, unrelated to any code you touched. Fix: php artisan migrate:fresh --seed to rebuild everything in one shot — but setting up .env.testing avoids the problem entirely.
Adding, changing, or removing onboarding keys
SetupCustomerAction no longer reads a raw array off $customer->onboarding — its input is OnboardingPayloadDto, built by OnboardingPayloadDto::fromCustomer(Customer $customer) (App\DataTransferObjects\Model\CustomerOnboarding\OnboardingPayloadDto). The payload shape is enforced by the type system: settings and admin are ?-typed properties holding the existing per-section DTOs (SaveSettingsDto, CreateAdminUserDto); birthdayEmail is a non-nullable OnboardingBirthdayEmailDto. (users/CreateOnboardingUsersDto was removed in EMMIE-0422 — see the users() step info box above. terminology/SaveTerminologyDto was removed in EMMIE-0430 — terminology is no longer part of the payload at all, see the terminology() step info box above.)
To add, rename, or remove a key:
- A field within an existing section (e.g. a new
settings.*field) — add it to the relevant leaf DTO's constructor, then add it toOnboardingPayloadDto::fromCustomer()'s corresponding block. The type system will flag every consumer that needs updating (SetupCustomerAction, the sub-action the leaf DTO feeds, and any test constructing that DTO) — a compile-time error, not a silent drift. - A whole new top-level section — add a new nullable property to
OnboardingPayloadDto(or a new leaf DTO if one doesn't exist yet, following the pattern of the existing ones), wire it intofromCustomer(), and add the correspondingif ($payload->x !== null)block inSetupCustomerAction::execute()— unless the data must always exist regardless of payload shape (seesettings, EMMIE-0923), in which case call the leaf Action unconditionally with a fallback-DTO default instead, followingseedOnboardingDefaults's pattern. OnboardingController::store()still assembles a plain array (see D6 inEMMIE-0414— noCustomerexists yet at the pointstore()builds it, so it can't callfromCustomer()directly). A dedicated test inCustomerOnboardingTestround-tripsstore()'s array shape throughfromCustomer()and asserts it matches a directly-constructedOnboardingPayloadDto— update that test's expected payload alongside any key change so the two stay proven compatible.
SetupCustomerActionTest (Unit) constructs OnboardingPayloadDto directly per test case, including a "minimum viable" case with only admin populated (the one remaining payload-gated critical section — seedOnboardingDefaults and settings (EMMIE-0923) are also critical but aren't payload-gated at all) and every other property null. If a new key becomes critical, add it there too.
INFO
Note on customer_id: customer_id is a column on the central onboarding table, not part of $customer->onboarding JSON. It is set by store() after the customer is created. It is NOT in fullOnboardingPayload() and does not need to be. CustomerOnboardingTest::beforeEach() creates Onboarding without customer_id (bypasses the controller) — this is intentional, since customer_id is only used by the getOnboardingStep() HTTP endpoint and not by the pipeline itself.
Files involved
As of writing, these are (most) of the logic-bearing files involved during the onboarding process. If "something" is going wrong, it might be because you touched any of these files (unknowningly). If you add a new file with scary logic that can fail, please add it to the list.
OnboardingControllerCreateCustomer(Job) — thin async wrapper, delegates to the two Actions belowProvisionCustomerActionRollbackCustomerProvisioningActionCreateDatabase(Job) (not ours)MigrateDatabase(Job) (not ours)CreateAwsBucket(Job)SetupCustomer(Job)OnboardCustomerActionSetupCustomerActionOnboardingPayloadDto(App\DataTransferObjects\Model\CustomerOnboarding\). Sole typed input forSetupCustomerAction, built viafromCustomer(). See "Adding, changing, or removing onboarding keys" above.SaveSettingsActionSeedOnboardingDefaultsAction/SeedDefaultMentorTypesAction/SeedDefaultCareTypesAction/SeedDefaultCareLegislationsAction/SeedDefaultInterestedEmailTemplatesAction/SeedDefaultTerminologyAction— auto-seeding composition root (EMMIE-0416+)CreateAdminUserActionVerifyDatabaseCreationAction/VerifyDatabaseMigrationAction/VerifyBucketCreationAction/VerifyCustomerOnboardingAction/CleanupOnboardingActionMailFromNoReply+UserInvite+CustomerSetup.Customer(model). We rely on theonboarding_stepcast.Onboarding(model)OnboardingUser(model) — dead-pending-EMMIE-0528 (EMMIE-0422), same ascare_types/care_legislations/mentor_types/terminology_singular/terminology_pluralonOnboarding. OnlyCleanupOnboardingAction's legacy-row-cleanup call still touches it.Role(model)
TODO
- How to actually deal with all the warnings and logs created by the revamped CreateCustomer job and SetupCustomerAction. Will be done in ticket EMMIE-0433 Onboarding Revamp - Failure logs
- What should we do with the warning popups that could appear during the createCustomer job?
- Emails sent to the admin of the new customer use the logo of the customer. Emails sent to the new users (that are invited to the new organization use something else) - Will be dealt with during onboarding Revamp.
- What happens to onboarding data when someone leaves the process before creating the actual organization? - EMMIE-0425 Onboarding Revamp - Abandoned onboarding table entries