Skip to content

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->name refers 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:
    • onboarding
    • onboarding_users
    • failed_jobs
    • domains
    • customers
  • emmie s3 bucket. We need this bucket to exist to use customer_logo. Minio needs to be running. Check localhost:9001 and 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? (CustomerSetup for Admin who created the org. UserInvite for every other user added during the process). Maildev should be running. Probably on http://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 what SetupCustomerAction reads. Built via OnboardingPayloadDto::fromCustomer(Customer $customer), which parses the raw Customer::onboarding payload into this DTO's typed properties (nullable per-section leaf DTOs, plus a non-nullable OnboardingBirthdayEmailDto). SetupCustomerAction is 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 the onboarding table in the Main Database. Also starts the CreateCustomer Job.
  • CreateCustomer (Job). A thin async queue wrapper — handle() delegates straight to ProvisionCustomerAction, failed() straight to RollbackCustomerProvisioningAction. 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, runs SetupCustomer — with a sanity check after every major step. Highly paranoid.
  • RollbackCustomerProvisioningAction. Rolls back a failed provisioning attempt based on onboarding_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, orchestrates SetupCustomerAction, sends invite/setup emails, and marks the customer as onboarded.
  • SetupCustomerAction. If a new database has been created, migrations were successful and the s3 bucket was created (CreateCustomer Job), this action fills the new database with information from the onboarding process using OnboardingPayloadDto. Quite paranoid, less destructive than CreateCustomer.
  • 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 into SetupCustomerAction, 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 composes SeedDefaultMentorTypesAction, SeedDefaultCareTypesAction, SeedDefaultCareLegislationsAction, SeedDefaultInterestedEmailTemplatesAction, and SeedDefaultTerminologyAction.

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.

  1. register() with CreateOnboardingRegistrationAction. Creates a new entry in the onboarding table in the MAIN DATABASE with admin_email and HASHED admin_password. Returns the token from this entry.
  2. admin() with UpdateOnboardingAdminAction. Updates onboarding entry with admin_first_name, admin_last_name and admin_phone. Returns only OkResponse.
  3. organizationName() with UpdateOnboardingOrganizationNameAction. Updates onboarding entry with name, kvk_number and logo (optional). Returns only OkResponse.

    WARNING

    logo is stored in emmie's own bucket. when running php artisan migrate:fresh --seed for local development, this can silently fail. Check localhost:9001 to see if there is a bucket called "emmie".

  4. address() with UpdateOnboardingAddressAction. Updates onboarding entry with: address_zipcode, address_number, address_number_addition (optional), address_street & address_city. Returns only OkResponse.
  5. auth() with UpdateOnboardingAuthAction. Optional. Updates onboarding entry with two_step_verification and max_session_duration. Only returns OkResponse.
  6. warning() with UpdateOnboardingWarningAction. All optional. Updates onboarding entry with: evaluation_interval, financing_start_interval, warning_expiring_financing & missing_registration_interval. Only returns OkResponse.
  7. birthday() with UpdateOnboardingBirthdayAction. Optional. Updates onboarding entry with: birthday_send_email, birthday_email_subject & birthday_email_content. Only returns OkResponse.
  8. mdo() with UpdateOnboardingMdoAction. Optional? Updates onboarding entry with mdo_enabled and mdo_interval. Only returns OkResponse.

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 (UPDATE on 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.

  1. Set the route on the zorgwet row itself, in the same save as the wet. The row carries both (care_legislations.financing_route and vecozo_financing_type), and ApplyCareLegislationRouteAction writes 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 — UpdateContractAction writes contracts.financing_route and 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.
  2. Count, then couple. financings:report-route-backfill prints 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.
  3. 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 — Generate323FileAction refuses 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

  1. Store(). The one step with no dedicated Action — the array assembly + Customer::create() + job dispatch stays in the controller (see D6 in EMMIE-0414; extracting this provisioning orchestrator into its own Action is out of scope for that ticket).
    • Fills $customerOnboarding array with data from onboarding table per category. (Used to also include onboarding_users for the users step — removed in EMMIE-0422.)
    • We create a new entry in the customers table in the Main Database. Does NOT trigger events.

      INFO

      $customerOnboarding ends up as one key inside the real data JSON column on customers (alongside other virtual attributes, e.g. intake_price). onboarding itself is not its own column and has no dedicated Eloquent JSON cast — Customer uses Stancl Tenancy's VirtualColumn trait, which reads/writes it in and out of data via magic getters/setters. Don't "fix" this later by adding a casts() entry for onboarding specifically.

    • 'onboarding_step' is set to DATABASE_SETUP.
    • We write $customer->id back to the onboarding record as customer_id. This links the token to the new customer without exposing a sequential ID in the URL.
    • we dispatch the CreateCustomer Job. We do NOT wait until it is finished.
    • Returns $customer->id.

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_id is null → returns ERROR (should not happen in normal flow).
  • Token found, customer_id set, but customer record not found → returns ERROR (customer was deleted, e.g. by failed()).
  • 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):

  1. We make $domain by taking the name of the organization ($customer->name).
  2. We make a new entry in the domains table in the Main Database.
  3. we saveQuitely to store tenancy_db_name on 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.

  1. CreateDatabase Job. We wait until it is finished.
  2. Sanity Check. VerifyDatabaseCreationAction checks whether a new database is created. Queries information_schema to 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 to failed().
  3. Passed the sanity check. We set the customer's onboarding_step to DATABASE_MIGRATION.
  4. MigrateDatabase Job. We wait until it is finished. Both MigrateDatabase and CreateAwsBucket run inside a single tenant context closure ($customer->run(...)).
  5. Sanity Check. VerifyDatabaseMigrationAction counts tables in the new tenant database, filtered by the explicit database name passed in (not DATABASE() — 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.
  6. CreateAwsBucket Job. We wait until it is finished.
  7. Sanity Check. VerifyBucketCreationAction verifies that CreateAwsBucket is fully completed - checks that the bucket field was persisted to the customer's DB record.
  8. Passed 2 sanity checks. We set the customer's onboarding_step to CUSTOMER_SETUP.
  9. SetupCustomer Job. 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().

  1. seedOnboardingDefaults. Critical. Uses SeedOnboardingDefaultsAction, called unconditionally — not gated on any OnboardingPayloadDto property. Currently seeds the 4 fixed mentor types via SeedDefaultMentorTypesAction (EMMIE-0416), activates both fixed care types via SeedDefaultCareTypesAction (EMMIE-0417), seeds all 5 fixed care legislations via SeedDefaultCareLegislationsAction (EMMIE-0420), seeds the interested-client e-mail templates via SeedDefaultInterestedEmailTemplatesAction (EMMIE-0451), and seeds the Dutch client-terminology row via SeedDefaultTerminologyAction (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.
  2. settings. Critical (EMMIE-0923). Uses SaveSettingsAction, called unconditionally — not gated on any OnboardingPayloadDto property, matching seedOnboardingDefaults above. Falls back to an all-null SaveSettingsDto when $payload->settings is absent; SaveSettingsAction already 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 missing Settings row causes a hard crash on login (No query results for model [Settings]) for every user of that tenant, per docs/onboarding/BreakingOnboarding.md. See docs/onboarding/MigrationsVsActions.md ("settings is a landmine") for the full investigation.
  3. admin. Critical. Cannot enter platform without this admin. Organisation would become unaccessable. If it fails, it throws back to the CreateCustomer Job which will trigger failed().
  4. 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 the CreateCustomer Job and triggers failed().
  5. 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.
  6. 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.
  7. 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. ​

  1. we use the $result to invite the user who made the organization with CustomerSetup, 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 users onboarding step, queuing a UserInvite email per created user (async). That loop — and the created_users field on SetupCustomerResultDto that 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 sends UserInvite.

  2. we set onboarded to true for this customer in the customers table in the Main Database. If there were no warnings, we also set Customer::onboarding (a virtual attribute backed by the data column, see the store() info box above) to null. If there are warnings, we don't — this is a deliberate exception where OnboardCustomerAction keeps touching the raw value directly rather than going through OnboardingPayloadDto (see D4 in EMMIE-0414).

Back to the SetupCustomer Job. ​

Back to ProvisionCustomerAction. ​

  1. Sanity Check. We verify that the customer has actually been onboarded. If it has not, execute() stops here and the exception propagates to failed().

  2. We passed the last sanity check. We update onboarding_step to FINISHED.

  3. cleanupOnboardingAction. We try to delete onboarding_users first, then the onboarding record. Because the FK constraint on onboarding_users.onboarding_id prevents 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 new onboarding_users row 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_users isn't dropped, pending EMMIE-0528).

  4. ProvisionCustomerAction::execute() is now complete. We do not return anything. Frontend has been asking backend for updates on onboarding_step.

Back to the OnboardingController. ​

  1. Once ProvisionCustomerAction has done its job, it has updated the onboarding_step to FINISHED. Frontend is now happy and ask for the domain link.
  2. getLoginLink will 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
                                └─▶ SendInviteEmails

On 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 attempts DeleteDatabase.
  • S3 bucket (step-conditional): attempted only if onboarding_step is DATABASE_MIGRATION or CUSTOMER_SETUP — the bucket is created during the DATABASE_MIGRATION step, before the step advances to CUSTOMER_SETUP, so both must be covered. DeleteAwsBucket itself 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): DeleteCustomerLogoAction runs regardless of onboarding_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_step to ERROR as a last-resort marker (the orphan tripwire is the actual safety net at that point).
  • deployOrphanTripwire always 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 for ProvisionCustomerAction's and RollbackCustomerProvisioningAction's orchestration and rollback. Normal unit test things do not work here. Also contains the OnboardingPayloadDto::fromCustomer() shape-compatibility test (see "Adding, changing, or removing onboarding keys" below) in the same describe() 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 the CreateCustomer job itself. Only asserts delegation — handle() calls ProvisionCustomerAction::execute(), failed() calls RollbackCustomerProvisioningAction::execute(). No real infrastructure; both Actions are mocked.
  • RollbackCustomerProvisioningActionTest (tests/Unit/app/Actions/Model/CustomerOnboarding/): UNIT test with real coverage value: it exercises the catch (Throwable) {} swallow branches around DeleteDatabase/DeleteAwsBucket, and deployOrphanTripwire()'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). Uses Model::setConnectionResolver() to make the static Customer::find() call inside deployOrphanTripwire() resolve without a real database connection.
  • SetupCustomerActionTest (tests/Unit/app/Actions/Model/CustomerOnboarding/): UNIT test. Constructs OnboardingPayloadDto directly (no Customer mock for onboarding needed) and mocks every injected sub-action.
  • SetupCustomerActionTest (tests/Integration/app/Actions/Model/CustomerOnboarding/): INTEGRATION test. Seeds the real Customer::onboarding virtual attribute with a raw array, builds the DTO via OnboardingPayloadDto::fromCustomer($this->tenant), and asserts against the real tenant database (real ProvidedCareType/CareLegislation/Terminology/User/Email records — no Mockery). Deliberately does NOT assert granular MentorType row content (EMMIE-0416) — that belongs to SeedOnboardingDefaultsActionTest below, 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 seeds Customer::onboarding with a raw array and asserts the parsed DTO matches.
  • The provisioning pipeline tests — ProvisionCustomerAction's and RollbackCustomerProvisioningAction'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 into ProvisionCustomerAction to 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:

  • Role is a central DB model. We NEED it to exist in the main database before everything else. Hence seedBeheerderRole().
  • @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.
  • Rector automatically messes up withTenancyTestCase.

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 --seed locally, 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 to OnboardingPayloadDto::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 into fromCustomer(), and add the corresponding if ($payload->x !== null) block in SetupCustomerAction::execute() — unless the data must always exist regardless of payload shape (see settings, EMMIE-0923), in which case call the leaf Action unconditionally with a fallback-DTO default instead, following seedOnboardingDefaults's pattern.
  • OnboardingController::store() still assembles a plain array (see D6 in EMMIE-0414 — no Customer exists yet at the point store() builds it, so it can't call fromCustomer() directly). A dedicated test in CustomerOnboardingTest round-trips store()'s array shape through fromCustomer() and asserts it matches a directly-constructed OnboardingPayloadDto — 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.

  • OnboardingController
  • CreateCustomer (Job) — thin async wrapper, delegates to the two Actions below
  • ProvisionCustomerAction
  • RollbackCustomerProvisioningAction
  • CreateDatabase (Job) (not ours)
  • MigrateDatabase (Job) (not ours)
  • CreateAwsBucket (Job)
  • SetupCustomer (Job)
  • OnboardCustomerAction
  • SetupCustomerAction
  • OnboardingPayloadDto (App\DataTransferObjects\Model\CustomerOnboarding\). Sole typed input for SetupCustomerAction, built via fromCustomer(). See "Adding, changing, or removing onboarding keys" above.
  • SaveSettingsAction
  • SeedOnboardingDefaultsAction / SeedDefaultMentorTypesAction / SeedDefaultCareTypesAction / SeedDefaultCareLegislationsAction / SeedDefaultInterestedEmailTemplatesAction / SeedDefaultTerminologyAction — auto-seeding composition root (EMMIE-0416+)
  • CreateAdminUserAction
  • VerifyDatabaseCreationAction / VerifyDatabaseMigrationAction / VerifyBucketCreationAction / VerifyCustomerOnboardingAction / CleanupOnboardingAction
  • MailFromNoReply + UserInvite + CustomerSetup.
  • Customer (model). We rely on the onboarding_step cast.
  • Onboarding (model)
  • OnboardingUser (model) — dead-pending-EMMIE-0528 (EMMIE-0422), same as care_types/care_legislations/mentor_types/terminology_singular/terminology_plural on Onboarding. Only CleanupOnboardingAction'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