Skip to content

This doc is a snap-shot of how things were at the start of the Onboarding Revamp Epic. No need to edit it.

Auditing the Onboarding Process ​

For the most accurate information about the backend side of the onboarding process, you should go to docs/onboarding/onboardingProcess.md. This file is mainly for identifying weird stuff in the process.

There are currently 4 different environments. We will be looking at the current development branch.

Steps ​

For both frontend and backend.

All data fields will be considered required unless stated otherwise.

Step 1 = Nog geen klant? Meld je aan ​

Route: localhost:8000.

When the user presses the link on Meld je aan, they are redirected to localhost:8000/registreren.

After this step, all pages are nested in OnboardingCreate.vue.

Step 2 = Basis for Admin User. ​

Route: localhost:8000/registren.

Asked for:

  • E-mailadres
  • password.

Without an admin user, there is no way to enter the new organization. Keep as is.

Frontend ​

Component: AdminForm.vue.

Backend ​

Submitting a valid email and password will post onboarding-register with just {email, password} to OnboardingController::register(). This creates the onboarding row in the Central Database. It will return a token. This token will be stored as onboardingId on frontend.

Additional notes. ​

  • Going back to this page, the route will be: localhost:8000/registreren?step=-1
  • it is not possible to use the same email for multiple organizations.
  • Only page without OnboardingForm.vue.

Step 3 = Introduction. ​

route: localhost:8000/registreren?step=0

Show information about duration of the onboarding process and the ability to change things later.

Keeping it seems fine. Should be adjusted if we start cutting steps. Could also be removed. Make a progress bar with clear steps to show the anticipated length of the whole process seems fine.

Frontend ​

Component: GetStarted.vue.

Backend ​

No backend call. GetStarted.vue simply calls goToNextStep() on submit — purely informational.

Step 4 = Admin user personal info. ​

Route: localhost:8000/registreren?step=1

Collects first name, last name, and phone number for the future admin user.

Frontend ​

Component: Introductory.vue.

On submit: POST onboarding-admin/{onboardingId} with {firstName, lastName, phoneNumber}.

Backend ​

Stores the personal info on the onboarding row in the central DB.

Quirks ​

  • Email (from Step 2) must be unique across all organizations. Phone number has no such constraint; the same phone number can be used by multiple admin users across different organizations.

Step 5 = Organization details. ​

Route: localhost:8000/registreren?step=2

Collects the organization name, KvK number, and an optional logo. Organization name and kvk number must be unique.

Frontend ​

Component: Organization.vue.

The title greets the user by name ("Hoi ${registrationForm.adminInfo.firstName}"), so Step 4 must have been completed for this greeting to be meaningful.

Logo handling: the file is read with FileReader.readAsDataURL() and stored as a base64 data URL in the reactive state. On submit, this base64 string is included in the POST body. If the logo is close to the 5 MB limit, the POST body can be several megabytes.

On submit: POST onboarding-organization-name/{onboardingId} with {name, logo, kvkNumber}.

Backend ​

Stores name, KvK number, and logo on the onboarding row.

Quirks ​

  • Logo is sent as a base64 data URL in the JSON body. OnboardingOrgNameRequest converts it to an UploadedFile via ConvertBase64ToUploadedFileTrait::prepareForValidation() before the controller runs, so $request->file('logo') works as expected. The logo is stored on the central Emmie S3 bucket via Storage::store(StoragePathConstant::CUSTOMER_LOGO).
  • The file size check (5 MB) is done on the raw File object before encoding. The base64 string sent over the wire is ~33% larger than the original file.
  • OnboardingController::organizationName() illudes to only validating the name of the organization but it also deals with Kvk number and an optional logo.

Step 6 = Address. ​

Route: localhost:8000/registreren?step=3

Collects the organization's physical address.

Frontend ​

Component: Address.vue. Uses the shared AddressFormColumn component.

AddressFormColumn watches zipcode, number, and numberAddition. Any change triggers useAddressLookup, which calls GET address?zipcode=...&number=.... The backend resolves this via the Pro6pp API and returns {street, city}, which are then auto-filled. The user can still type street and city manually if the lookup fails or returns nothing.

Minimal client-side guard before posting: checks that street and city are not null.

On submit: POST onboarding-address/{onboardingId} with the address object.

Backend ​

The GET address endpoint calls Pro6ppService::getStreetAndCity() (v1 API), which returns the street and city for a given postcode + house number.

The POST onboarding-address endpoint uses OnboardingAddressRequest, which validates format only — it does not call Pro6pp again. The Pro6pp lookup is autocomplete only, not a submit-time validation gate.

Quirks ​

  • The Pro6pp auth key must be configured in .env (services.pro6pp.key). If it is missing or expired, every address lookup silently fails with a cURL/SSL error and the user must enter street and city manually — no error is shown to them.
  • The submit-time validation (OnboardingAddressRequest) only checks postcode format (Dutch regex /^[1-9]\d{3}\s?[A-Za-z]{2}$/). A user who ignores the autocomplete and types a wrong street or city will pass validation.

Step 7 = Organization type (care legislation + care types). ​

Route: localhost:8000/registreren?step=4

The organization selects which care legislation frameworks apply (e.g. WLZ, WMO) and which type(s) of care they deliver.

Frontend ​

Component: OrganizationType.vue.

Both multiselects pull their options from frontend constants in admin/domains/on-boarding/constants.ts, not from the API.

On submit: POST onboarding-care/{onboardingId} with {careLegislations, careTypes}.

Backend ​

Passes the selected values to SaveOnboardingTypesAction, which creates CareLegislation records and activates the matching ProvidedCareType records. ProvidedCareType records must already exist in the tenant DB (seeded via migrations) — the action only activates them; it does not create them.

Quirks ​

  • The option lists are hardcoded frontend constants. If the backend's list of valid care types drifts from the frontend constants, the submit silently sends invalid data with no mismatch warning to the user.
  • ProvidedCareType records must pre-exist in the tenant DB. If they are missing (e.g. a migration wasn't run), the activation step fails silently or errors at job time, not during this step.

Suggested changes ​

Currently, if the caretype the user wants to chose doesn't exist in the list, they have to:

  • Pick the wrong one.
  • Complete the onboarding process.
  • Change it later. This seems dumb. Maybe add an input field to allow the user to add their own?

Otherwise: We could just add all these to the organisation and skip this step.

Same can be said about zorgwetten but I have no knowledge about these laws.

Step 8 = Mentor types. ​

Route: localhost:8000/registreren?step=5

The organization selects which begeleider types apply to them (e.g. Begeleider, Gezinscoördinator, Casemanager, Zorgcoördinator).

Frontend ​

Component: MentorTypes.vue.

Options come from admin/domains/on-boarding/constants.ts. These map directly to MentorTypeOnboardingSuggestionEnum on the backend — 4 hardcoded entries with IDs 1–4.

WARNING

We are not asking the backend for the mentortypes. They are hardcoded in the frontend.

On submit: POST onboarding-mentors-type/{onboardingId} with {mentorTypeIds: [...]}.

Backend ​

SaveOnboardingTypesAction creates MentorType records with forced IDs matching the enum ($type->id = (int) $mentorTypeId). After this, CreateAdminUserAction calls MentorType::firstOrFail() and uses whatever row has the lowest ID as the admin's mentor type — implicitly depending on IDs 1–4 being inserted in order.

Quirks ​

  • The mentor type options are entirely static. There is no way to define a custom type during onboarding; you must do it post-onboarding.
  • The 4 hardcoded IDs are shared between frontend constants, the PHP enum, and the database. They are not coordinated by a single source of truth.
  • CreateAdminUserAction uses firstOrFail() to pick the admin's mentor type — it silently takes ID 1 (Begeleider) regardless of what the customer selected or whether they even want that type.
  • No way to add a new begeleider type if the user wants to.

Suggested changes. ​

  • At the bare minimum, a getrequest for mentor_types.
  • Change the way begeleider types are used during onboarding. We depend quite heavily on them and the admin user is always assigned the first one that they have selected for the organization.
  • Remove this step and have SetupCustomerAction put all mentor_types in the new database.

Step 9 = Invite colleagues. ​

Route: localhost:8000/registreren?step=6

The organization can invite additional employees. This step is skippable.

Frontend ​

Component: UserInvite.vue.

Skip mechanism: a "Ik doe dit op een later moment" checkbox. Under the hood this is a computed property: checked = users.length === 0, unchecked = add a blank user entry. Unchecking adds one user row; re-checking clears the entire array.

For each user: first name, last name, email, role(s), and mentor type(s).

Roles are fetched from the backend on onBeforeMount via GET onboarding-roles. This is the only GET request in all 13 step components — every other step is POST-only.

Selectable mentor types are derived from mentorTypeOnboardingSuggestionEnumCollection filtered by whatever was selected in Step 8. This means the mentor type IDs assigned to colleagues are the same hardcoded enum IDs.

A watch on registrationForm.mentorTypeIds strips any mentor type from a colleague that is no longer in the global selection (in case the user navigated back and changed Step 8).

On submit: POST onboarding-users/{onboardingId} with {users: [...]}.

Backend ​

CreateOnboardingUsersAction creates User records, generates invite tokens, and attaches mentor types and roles by ID with attach(). No pre-validation that those IDs exist in the tenant DB.

Quirks ​

  • Roles are central DB records (seeded by RoleSeeder). If roles aren't seeded, onboarding-roles returns an empty array and the step's role selector is silently empty. Any users saved with an empty roles array will have no roles after onboarding. This is also why the CreateCustomer Integration Test is so brutal.
  • CreateOnboardingUsersAction attaches roles by the IDs returned from the roles endpoint. There is no check that these central DB role IDs actually exist in the tenant DB at attach time.
  • The skip mechanism (users.length === 0) means the backend receives an empty array, which is valid — CreateOnboardingUsersAction just loops over nothing and returns an empty list.
  • A new collegue can be anything but a begeleider (role) but still be assigned a begeleider type (mentor_type). This is stupid.

Suggested changes ​

  • When the user fills in information about a collogue and misclicks /fat-fingers ik doe dit op een later moment, all data for the added collegue is gone. This is annoying.
  • Compared to new collegues, we are never asking what kind of role the admin user has. We are automatically assigning it the beheerder role (which is good for many reasons). What if they have more roles than just beheerder?
  • Remove this entire step. Unnecessary.

Step 10 = Terminology. ​

Route: localhost:8000/registreren?step=7

The organization defines how they refer to their clients (singular and plural, e.g. "bewoner" / "bewoners").

Frontend ​

Component: Terminology.vue.

On submit: POST onboarding-terminology/{onboardingId} with {singular, plural}.

Backend ​

SaveTerminologyAction stores the values. These feed into the terminologyTranslationService used throughout the frontend app.

Quirks ​

  • Title asked about how the organization works. Actual content asks about how you want to call your clients. Inconsistent.

Suggestions ​

  • Change title.
  • Remove step and use Dutch defaults. Having an empty table for terminology will cause problems so have backend put in Dutch defaults.

Step 11 = Authentication settings. ​

Route: localhost:8000/registreren?step=8

The organization configures two-factor authentication policy and maximum session duration.

Frontend ​

Component: Authentication.vue.

Both fields are optional/nullable. 2FA is a single-select enum; session duration is a number input.

On submit: POST onboarding-auth/{onboardingId} — sends the entire registrationForm object, not just the auth fields. The backend must pick out what it needs.

Backend ​

SaveSettingsAction reads the auth-related fields from the payload.

Quirks ​

  • The entire registrationForm state (organization name, address, all users, logo as base64, etc.) is posted in a single request. This is a very large payload sent every time the user clicks Next on this step. It appears to be an oversight — every other step sends only its own slice of the form.
  • The options do not specifically mention who they refer to (They state gebruikers which alludes to everyone who uses that platform but not every gebruiker is always a gebruiker. See ticket: EMMIE-0311 Inconsistency - Use of gebruiker / medewerker / client / user/ etc. for additional suffering). Be more precise and be consistent with ticket EMMIE-0335: Inconsistencies - Instellingen.
  • Unsure whether requiring employees to use 2fa is mandatory. I think it should be for any user that can a lot of things in instellingen.

Step can be moved from onboarding and placed "somewhere" else once the admin has succesfully created the new organisation.

Step 12 = Warning intervals. ​

Route: localhost:8000/registreren?step=9

The organization sets up how many days before various events Emmie should warn them (evaluation overdue, financing expiry, missing registrations, etc.).

Frontend ​

Component: Warnings.vue.

All four fields are nullable numbers. No required fields — the user can leave all of them blank and proceed.

On submit: POST onboarding-warnings/{onboardingId} with {evaluationInterval, financingStartInterval, warningExpiringFinancing, missingRegistrationInterval}.

Backend ​

Stores the values in the customer settings. These drive the warning system in the employee app.

Quirks ​

  • No minimum/maximum value validation on the frontend. A user can enter 0 or a very large number. This is validated by the backend.
  • We are not using the provided terminology for what to call a client (unlike most other places on the platform).
  • The UI is unclear whether the provided examples in each input are suggestions or default values. No input is required. Backend does use default values if no inputs are given.

Suggestions ​

  • Be more clear about what the examples given mean.
  • Be more clear about the fact that we have default values.
  • Be more clear that, if no inputs are given, we are using default values.
  • Might as well remove the whole step. None of these inputs are required. It does not give shape to the organization either.

Step 13 = Birthday emails. ​

Route: localhost:8000/registreren?step=10

The organization configures whether to send birthday emails to clients and what the template looks like.

Frontend ​

Component: Birthdays.vue.

Email content uses RichTextEditor (rich HTML). Defaults are pre-filled in state.ts — subject "Gefeliciteerd met je verjaardag!" and a Dutch body with merge tags.

Before posting, the content is passed through removeCanary(). This strips a canary marker that RichTextEditor injects to detect empty-but-not-empty states.

On submit: POST onboarding-birthdays/{onboardingId} with {sendEmail, emailSubject, emailContent}.

Quirks ​

  • The default emailContent in state.ts contains the merge tag {organisatie}, but the UI only lists {voornaam}, {aanwezigen}, {gebruiker}, {datum}, {tijd} as supported tags. {organisatie} is in the default but not documented in the UI — unclear if it is actually resolved by the backend.
  • sendEmail defaults to true in the initial state, so birthday emails are opt-out, not opt-in.

Suggestions ​

Remove this step. Kill it with fire.

  • Pure bloat.
  • The user can remove both the subject text and body text. Frontend does not check during submitting of this step.
  • Backend happily accepts this.
  • The frontend has no restore default-email-template in case the user realises half-way through that this is stupid to do during the onboarding process.
  • During the SetupCustomerAction we have some failsafes but we should just not do this here so less can go wrong during the OnboardingProcess.

Step 14 = MDO settings. ​

Route: localhost:8000/registreren?step=11

The organization decides whether to use multidisciplinary consultation (MDO) features and sets the MDO interval.

Frontend ​

Component: MDOSettings.vue.

mdoEnabled defaults to true in the initial state. The interval field is only shown when mdoEnabled is true.

On submit: POST onboarding-mdo/{onboardingId} with {mdoEnabled, mdoInterval}.

Quirks ​

  • MDO is enabled by default — the user must actively choose "Nee" to disable it. Organisations that don't use MDO may not notice.
  • We are not using the provided terminology for what to call a client (unlike most other places on the platform). (same as warnings step).
  • The UI is unclear whether the provided examples in each input are suggestions or default values. No input is required. Backend does use default values if no inputs are given.(same for warnings step).
  • This is the only place where the user can enable the MDO-module (which is a paid feature). This is the only the paid module that the user can enable themselves. They cannot disable this themselves after this has been enabled after the onboarding process.

Suggestions (same as warnings step). ​

  • Be more clear about what the examples given mean.
  • Be more clear about the fact that we have default values.
  • Be more clear that, if no inputs are given, we are using default values.
  • Might as well remove the whole step. None of these inputs are required. It does not give shape to the organization either.

Step 15 = Finish. ​

Route: localhost:8000/registreren?step=12

The final screen. The user can review their choices, then trigger environment creation.

Frontend ​

Component: Finish.vue.

Unlike all other steps, this component does not use the standard OnboardingForm submit button. It passes :show-buttons="false" and delegates the actual submission to a separate OnboardingSubmit.vue component. Finish.vue only manages a "review choices" modal and the previous-step button visibility.

OnboardingSubmit.vue handles the actual POST that dispatches the CreateCustomer job and then polls or waits for the completion email.

Backend ​

OnboardingController dispatches the CreateCustomer queued job. The job runs the full pipeline: create database → migrate → create AWS bucket → SetupCustomerAction → send invite email.

Quirks ​

  • The submit logic is split across two components (Finish.vue + OnboardingSubmit.vue). Not a bug, but the separation is non-obvious.
  • Once submitted, there is no way to cancel. The job has $tries = 1 — if it fails mid-pipeline, the failed() method attempts a rollback (drop DB, delete bucket, delete customer record), but this is best-effort.

File notes ​

Observations about specific files.

The header URL that wraps the logo in every mail::message email is hardcoded in config/mail.php:

php
'header' => [
    'url' => 'https://emmie.nl',

message.blade.php passes this directly: @component('mail::header', ['url' => config('mail.header.url')]). Both CustomerSetup and UserInvite use this layout, so their logos always link to emmie.nl rather than the customer's own tenant URL.


UserInvite.php + message.blade.php — broken logo in queued mails ​

message.blade.php builds the logo at render time by calling tenant() to retrieve the customer and then fetching their logo from S3 as a base64 data URI:

php
$customer = tenant();
$logo = tenancy()->central(function() use ($customer) {
    $storageUrl = Storage::temporaryUrl($customer->logo, now()->addMinutes(5));
    return 'data:image/png;base64,' . base64_encode(file_get_contents($storageUrl));
    // Falls back to 1×1 transparent GIF on any error
});

UserInvite extends MailFromNoReply which implements ShouldQueue. When the queue worker picks it up, no tenant context is initialized, so tenant() returns null. The S3 fetch throws, the catch (\Throwable) fallback returns a 1×1 transparent GIF, and EmbedEmailImages embeds that as a CID attachment — which renders as a broken image icon in email clients.

CustomerSetup avoids this because it is sent synchronously (->send()) inside $customer->run(), so tenant context is still active when the view renders.

A secondary issue: logo upload is optional during onboarding, so even if tenant context were available, $customer->logo may be null for newly created organizations.

The comment in message.blade.php acknowledges the fragility: "Really dirty fix for mails not working. Will need to clean this up later." (from 2 years ago lmao)


SetupCustomerAction.php ​

SaveOnboardingTypesAction handles mentor types, care types, and care legislations in one action via a single SaveOnboardingTypesDto. Care types and care legislations belong together — they both describe what kind of care the organisation delivers. Mentor types are a different concern — they describe how staff are classified — and only end up here because CreateAdminUserAction calls MentorType::firstOrFail(), so mentor types must exist before the admin user is created. The grouping is an ordering dependency masquerading as a conceptual one.

saveIntroductoryMeetingEmail() is in a weird place. It is meant to create a template for an email that is send to clients when they are invited to an intake gesprek. This email is only used when the organisation has the paid module instroom which is impossble to have enabled after the first onboarding process (as only emmie admins can enable this). Emails as a whole are in a weird place anyway.

Cross-cutting quirks ​

These apply to the wizard as a whole, not to any single step.

Validation is server-side only ​

With the exception of Address.vue (which checks that street and city are not null before posting), no step does any client-side validation. Every error requires a round-trip to the server before the user sees feedback. This is consistent and simple, but means slower feedback on typos and missing fields.

No repository layer ​

Every step component calls postRequest/getRequest directly. There is no repository.ts for the onboarding domain, unlike every other domain in the app. This means the list of onboarding API endpoints is scattered across 13 files.

State is lost on page refresh ​

registrationForm and onboardingId are module-level Vue refs. Refreshing the browser resets both to their defaults. The URL preserves the ?step= parameter, so the wizard would land on the right visual step with an empty form and no onboardingId — the next POST would use undefined as the ID and fail.

URL step can be jumped manually ​

Navigating directly to ?step=10 with no onboardingId bypasses all earlier steps. The backend validates the token on each endpoint, so fabricated data won't be accepted, but the UX breaks silently.

Subscription modules are invisible to new customers ​

Three sidenav items — Instroom, MDO, and Berichten — are gated on subscription flags on the central Customer record (interested_enabled, mdo_enabled, messages_enabled). These are billing add-ons toggled exclusively by a platform admin (Emmie staff) in the admin panel's subscriptions card. They are not set during onboarding (except MDO, which has its own onboarding step). A new customer has no way to discover these features exist or to enable them themselves — they only appear in the sidenav after a platform admin flips the switch. There is no in-app indication that these modules exist but are disabled.

Fragile step ordering on the backend ​

Several backend actions implicitly depend on earlier steps having run:

  • CreateAdminUserAction calls MentorType::firstOrFail() — requires Step 8 to have saved at least one mentor type.
  • CreateDefaultLocation uses the address saved in Step 6.
  • ContactClientRelationType 'persoonlijk begeleider' must exist post-migration (independent of any step, but a hidden prerequisite).
  • Role records must be seeded in the central DB before Step 9 can return meaningful role options.

Machete Actions ​

This section describes hypothetical cutting-of-steps and what their implications would be.

Removing begeleidersrollen - mentortypes from the onboarding process. ​

If we are not letting the user (admin) add any begeleider types during the onboarding process and we invisible give that same admin the first selected begeleider type anyway, why not remove that whole step entirely? The types already exist in MentorTypeOnboardingSuggestionEnum in the backend, might as well automatically add them to the organization as begeleider type suggestions. The onboarding step is basically a shortcut to pre-populate those roles for the new organization. We can replace it with a guided tour which we do not appear to have. For a platform as large as Emmie, this might be smart to have anyway.

Removing this step does have heavy implications: Hard crashes:

  • CreateAdminUserAction::firstOrFail() — Obviously fails if it the mentorType doesn't exist. There is no "logical" reason the new admin must be a mentor except for how mentortypes are used to deal with caseload things.
  • ClientController has another firstOrFail() on mentor type when displaying a client's mentor details.

Logic breakage (no crash but wrong behaviour):

  • User::canAccessOutsideCaseload() returns false if the user has no mentor types — users without a mentor type are effectively locked out of client access that their role might otherwise allow.
  • Client global scopes filter based on mentor type relationships — clients can silently disappear from lists if their assigned mentor's type is soft-deleted.

Graceful degradation (silent but visible):

  • Risk assessment and care agreement PDFs render a blank mentor type name.
  • Frontend client overview table loses mentor type columns and filter if the store is empty.
  • User form's mentor type multiselect becomes empty but doesn't crash.

The caseload logic is the real concern — removing the mandatory mentor type assignment from the admin user would mean they can't access clients outside their caseload even with the right role, because the mentor type check comes back false on an empty collection. That's a functional blocker before you even get to the guided tour.

So the dependency is deep enough that removing Step 8 without also reworking the caseload access logic would leave the admin user partially broken in the main app.

Removing Adding collegues - Medewerkers toevoegen ​

Bloat for the onboarding process. Instead, use a guided tour to add new employees. Removing it would:

  • Save an onboarding step.
  • Remove a step that can go wrong in the SetupCustomerAction.
  • Removes the need to send the emails to these employees during the CreateCustomer job which is already delecate (and can also fail).

Removing warnings - Wanneer mogen we je waarschuwen? ​

Only contains non-critical inputs. Frontend needs work to make it consistent. Should probably be part of a guided tour.

Removing MDO ​

Only contains non-critical inputs. Frontend needs work to make it consistent. Should probably be part of a guided tour.

Remove birthday email ​

kill with fire. Only causes problems. Pure bloat.


File Index ​

Everything touched by the onboarding process, ordered by layer. Probably complete-ish.

GroupFiles
Frontend — Page & Shell5
Frontend — Step Components13
Frontend — Shared5
Backend — Routes1
Backend — Controller3
Backend — Jobs5
Backend — Actions (CustomerOnboarding)12
Backend — Actions (Other)4
Backend — Services1
Backend — Form Requests13
Backend — Mails5
Backend — Central Models4
Backend — Tenant Models6
Backend — DTOs7
Backend — Interfaces6
Backend — Enums & Traits5
Backend Tests35
Frontend Tests8
Total138

Frontend — Page & Shell ​

FilenamePathPurpose
OnboardingCreate.vuefrontend/apps/admin/domains/on-boarding/pages/Outer wrapper; hosts all wizard steps
OnboardingForm.vuefrontend/apps/admin/domains/on-boarding/components/Shared form shell (back/next buttons)
OnboardingSubmit.vuefrontend/apps/admin/domains/on-boarding/components/Final POST + polls for job completion
OnboardingReviewModal.vuefrontend/apps/admin/domains/on-boarding/components/Review modal shown on Step 15 before submitting
AdminForm.vuefrontend/apps/admin/domains/on-boarding/components/Step 2: email + password entry

Frontend — Step Components ​

FilenamePathPurpose
GetStarted.vuefrontend/apps/admin/domains/on-boarding/components/steps/Step 3: intro screen, no backend call
Introductory.vuefrontend/apps/admin/domains/on-boarding/components/steps/Step 4: admin personal info
Organization.vuefrontend/apps/admin/domains/on-boarding/components/steps/Step 5: org name, KvK, logo
Address.vuefrontend/apps/admin/domains/on-boarding/components/steps/Step 6: organization address
OrganizationType.vuefrontend/apps/admin/domains/on-boarding/components/steps/Step 7: care legislations + care types
MentorTypes.vuefrontend/apps/admin/domains/on-boarding/components/steps/Step 8: begeleider types
UserInvite.vuefrontend/apps/admin/domains/on-boarding/components/steps/Step 9: invite colleagues
Terminology.vuefrontend/apps/admin/domains/on-boarding/components/steps/Step 10: client terminology
Authentication.vuefrontend/apps/admin/domains/on-boarding/components/steps/Step 11: 2FA + session settings
Warnings.vuefrontend/apps/admin/domains/on-boarding/components/steps/Step 12: warning intervals
Birthdays.vuefrontend/apps/admin/domains/on-boarding/components/steps/Step 13: birthday email settings
MDOSettings.vuefrontend/apps/admin/domains/on-boarding/components/steps/Step 14: MDO settings
Finish.vuefrontend/apps/admin/domains/on-boarding/components/steps/Step 15: review + trigger creation

Frontend — Shared ​

FilenamePathPurpose
AddressFormColumn.vuefrontend/apps/shared/components/address/Address form with Pro6pp autocomplete
useAddressLookup.tsfrontend/apps/shared/composables/Calls address API, auto-fills street + city
state.tsfrontend/apps/admin/domains/on-boarding/Reactive form state for the whole wizard
constants.tsfrontend/apps/admin/domains/on-boarding/Hardcoded care types, legislations, mentor types
OnboardingLogin.vuefrontend/apps/auth/pages/Login page the admin lands on after org creation

Backend — Routes ​

FilenamePathPurpose
admin.phpbackend/routes/All onboarding endpoints (16 routes) + onboarding-roles via RoleController and address via AddressController

Backend — Controller ​

FilenamePathPurpose
OnboardingController.phpbackend/app/Http/Admin/Controllers/All 13 wizard POST endpoints + store + polling
RoleController.phpbackend/app/Http/Admin/Controllers/Provides role list for step 9 (user invite) via onboarding-roles
AddressController.phpbackend/app/Http/Controllers/Pro6pp address lookup (zipcode → street + city) for step 6

Backend — Jobs ​

FilenamePathPurpose
CreateCustomer.phpbackend/app/Jobs/Orchestrates DB creation, migration, bucket, and setup
CreateAwsBucket.phpbackend/app/Jobs/Creates S3/MinIO bucket for the new tenant
DeleteAwsBucket.phpbackend/app/Jobs/Deletes the bucket during rollback in failed()
SetupCustomer.phpbackend/app/Jobs/Delegates to OnboardCustomerAction; falls back to CreateCustomerAdmin
CreateCustomerAdmin.phpbackend/app/Jobs/Fallback: creates admin user + sends invite when setup was already done

Backend — Actions (CustomerOnboarding) ​

FilenamePathPurpose
OnboardCustomerAction.phpbackend/app/Actions/Model/CustomerOnboarding/Orchestrator: runs SetupCustomerAction + sends welcome/invite emails
SetupCustomerAction.phpbackend/app/Actions/Model/CustomerOnboarding/Populates new tenant DB from onboarding data
SaveTerminologyAction.phpbackend/app/Actions/Model/CustomerOnboarding/Saves client terminology (non-critical)
SaveSettingsAction.phpbackend/app/Actions/Model/CustomerOnboarding/Saves auth + session settings (non-critical)
SaveOnboardingTypesAction.phpbackend/app/Actions/Model/CustomerOnboarding/Saves care types, legislations, mentor types (critical)
CreateAdminUserAction.phpbackend/app/Actions/Model/CustomerOnboarding/Creates the admin user (critical)
CreateOnboardingUsersAction.phpbackend/app/Actions/Model/CustomerOnboarding/Creates invited colleagues (non-critical)
VerifyDatabaseCreationAction.phpbackend/app/Actions/Model/CustomerOnboarding/Sanity check: DB was created
VerifyDatabaseMigrationAction.phpbackend/app/Actions/Model/CustomerOnboarding/Sanity check: migrations ran
VerifyBucketCreationAction.phpbackend/app/Actions/Model/CustomerOnboarding/Sanity check: S3 bucket exists
VerifyCustomerOnboardingAction.phpbackend/app/Actions/Model/CustomerOnboarding/Sanity check: customer is onboarded
CleanupOnboardingAction.phpbackend/app/Actions/Model/CustomerOnboarding/Deletes onboarding + onboarding_users records

Backend — Actions (Other) ​

FilenamePathPurpose
CreateDefaultLocationAction.phpbackend/app/Actions/Model/Location/Creates 'Hoofdlocatie' from onboarding address
CreateCustomerAdminAction.phpbackend/app/Actions/Model/User/Creates the admin user in the tenant DB (fallback path via CreateCustomerAdmin job)
SendCustomerAdminInviteAction.phpbackend/app/Actions/Model/User/Sends invite email to the new admin (fallback path)
RollbackCustomerAdminCreationAction.phpbackend/app/Actions/Model/User/Rolls back admin user creation if CreateCustomerAdmin job fails

Backend — Services ​

FilenamePathPurpose
Pro6ppService.phpbackend/app/Services/Location/Address lookup via Pro6pp API

Backend — Form Requests ​

FilenamePathPurpose
OnboardingRequest.phpbackend/app/Http/Admin/Requests/OnboardingRequests/Base request class
OnboardingRegisterRequest.phpbackend/app/Http/Admin/Requests/OnboardingRequests/Step 2: email + password
OnboardingAdminRequest.phpbackend/app/Http/Admin/Requests/OnboardingRequests/Step 4: admin personal info
OnboardingOrgNameRequest.phpbackend/app/Http/Admin/Requests/OnboardingRequests/Step 5: name, KvK, logo + base64 conversion
OnboardingAddressRequest.phpbackend/app/Http/Admin/Requests/OnboardingRequests/Step 6: address (format-only validation)
OnboardingCareRequest.phpbackend/app/Http/Admin/Requests/OnboardingRequests/Step 7: care types + legislations
OnboardingMentorsTypeRequest.phpbackend/app/Http/Admin/Requests/OnboardingRequests/Step 8: mentor type IDs
OnboardingUsersRequest.phpbackend/app/Http/Admin/Requests/OnboardingRequests/Step 9: invited users
OnboardingTerminologyRequest.phpbackend/app/Http/Admin/Requests/OnboardingRequests/Step 10: terminology
OnboardingAuthRequest.phpbackend/app/Http/Admin/Requests/OnboardingRequests/Step 11: auth settings (receives entire form)
OnboardingWarningRequest.phpbackend/app/Http/Admin/Requests/OnboardingRequests/Step 12: warning intervals
OnboardingBirthdaySettingsRequest.phpbackend/app/Http/Admin/Requests/OnboardingRequests/Step 13: birthday email
OnboardingMDOSettingsRequest.phpbackend/app/Http/Admin/Requests/OnboardingRequests/Step 14: MDO settings

Backend — Mails ​

FilenamePathPurpose
MailFromNoReply.phpbackend/app/Mail/Base mailer class
CustomerSetup.phpbackend/app/Mail/Welcome email to admin of new org
CustomerSetup.blade.phpbackend/resources/views/emails/Blade template for CustomerSetup mail
UserInvite.phpbackend/app/Mail/Invite email to colleagues added during onboarding
UserInvite.blade.phpbackend/resources/views/emails/Blade template for UserInvite mail

Backend — Central Models ​

FilenamePathPurpose
Onboarding.phpbackend/app/Models/Admin/Central DB onboarding record
OnboardingUser.phpbackend/app/Models/Admin/Central DB record for each invited colleague during onboarding
Customer.phpbackend/app/Models/Admin/Central DB customer record (holds onboarding_step)
Role.phpbackend/app/Models/Admin/Role lookup used by CreateAdminUserAction to assign the admin role

Backend — Tenant Models ​

Written to by SetupCustomerAction when populating the new tenant database.

FilenamePathPurpose
CareLegislation.phpbackend/app/Models/Customer/Stores selected care legislation frameworks
Email.phpbackend/app/Models/Customer/Stores birthday + introductory meeting email templates
MentorType.phpbackend/app/Models/Customer/Stores begeleider types with forced IDs
ProvidedCareType.phpbackend/app/Models/Customer/Activated care types (must pre-exist via migration)
Settings.phpbackend/app/Models/Customer/Stores auth, session, warning, MDO settings
Terminology.phpbackend/app/Models/Customer/Stores client terminology (singular/plural)

Backend — Interfaces ​

FilenamePathPurpose
CreateAdminUserInterface.phpbackend/app/Interfaces/Model/CustomerOnboarding/Contract for CreateAdminUserAction
CreateOnboardingUsersInterface.phpbackend/app/Interfaces/Model/CustomerOnboarding/Contract for CreateOnboardingUsersAction
OnboardingUserInterface.phpbackend/app/Interfaces/Model/CustomerOnboarding/Contract for a single onboarding user
SaveOnboardingTypesInterface.phpbackend/app/Interfaces/Model/CustomerOnboarding/Contract for SaveOnboardingTypesAction
SaveSettingsInterface.phpbackend/app/Interfaces/Model/CustomerOnboarding/Contract for SaveSettingsAction
SaveTerminologyInterface.phpbackend/app/Interfaces/Model/CustomerOnboarding/Contract for SaveTerminologyAction

Backend — DTOs ​

FilenamePathPurpose
CreateAdminUserDto.phpbackend/app/DataTransferObjects/Model/CustomerOnboarding/Input for CreateAdminUserAction
CreateOnboardingUsersDto.phpbackend/app/DataTransferObjects/Model/CustomerOnboarding/Input for CreateOnboardingUsersAction
OnboardingUserDto.phpbackend/app/DataTransferObjects/Model/CustomerOnboarding/Single invited user within CreateOnboardingUsersDto
SaveOnboardingTypesDto.phpbackend/app/DataTransferObjects/Model/CustomerOnboarding/Input for SaveOnboardingTypesAction
SaveSettingsDto.phpbackend/app/DataTransferObjects/Model/CustomerOnboarding/Input for SaveSettingsAction
SaveTerminologyDto.phpbackend/app/DataTransferObjects/Model/CustomerOnboarding/Input for SaveTerminologyAction
SetupCustomerResultDto.phpbackend/app/DataTransferObjects/Model/CustomerOnboarding/Return value of SetupCustomerAction

Backend — Enums & Traits ​

FilenamePathPurpose
OnboardingStepEnum.phpbackend/app/Enums/Pipeline stage tracker (DATABASE_SETUP → FINISHED)
MentorTypeOnboardingSuggestionEnum.phpbackend/app/Enums/4 hardcoded mentor type IDs
EmailNameEnum.phpbackend/app/Enums/Names for email templates (birthday, introductory meeting)
TerminologyEnum.phpbackend/app/Enums/Sanity check key for terminology persistence
ConvertBase64ToUploadedFileTrait.phpbackend/app/Http/Requests/Traits/Converts base64 logo to UploadedFile pre-validation

Backend Tests ​

FilenamePathPurpose
CreateCustomerTest.phpbackend/tests/Integration/app/Jobs/CreateCustomer/Integration test: full job + all rollback scenarios
CreateCustomerAdminTest.phpbackend/tests/Integration/app/Jobs/CreateCustomerAdmin/Integration test for CreateCustomerAdmin job
SetupCustomerActionTest.phpbackend/tests/Integration/app/Actions/Model/CustomerOnboarding/Integration test for SetupCustomerAction
CleanupOnboardingActionTest.phpbackend/tests/Unit/app/Actions/Model/CustomerOnboarding/Unit test for CleanupOnboardingAction
CreateAdminUserActionTest.phpbackend/tests/Unit/app/Actions/Model/CustomerOnboarding/Unit test for CreateAdminUserAction
CreateOnboardingUsersActionTest.phpbackend/tests/Unit/app/Actions/Model/CustomerOnboarding/Unit test for CreateOnboardingUsersAction
OnboardCustomerActionTest.phpbackend/tests/Unit/app/Actions/Model/CustomerOnboarding/Unit test for OnboardCustomerAction
SaveOnboardingTypesActionTest.phpbackend/tests/Unit/app/Actions/Model/CustomerOnboarding/Unit test for SaveOnboardingTypesAction
SaveSettingsActionTest.phpbackend/tests/Unit/app/Actions/Model/CustomerOnboarding/Unit test for SaveSettingsAction
SaveTerminologyActionTest.phpbackend/tests/Unit/app/Actions/Model/CustomerOnboarding/Unit test for SaveTerminologyAction
SetupCustomerActionTest.phpbackend/tests/Unit/app/Actions/Model/CustomerOnboarding/Unit test for SetupCustomerAction
VerifyBucketCreationActionTest.phpbackend/tests/Unit/app/Actions/Model/CustomerOnboarding/Unit test for VerifyBucketCreationAction
VerifyCustomerOnboardingActionTest.phpbackend/tests/Unit/app/Actions/Model/CustomerOnboarding/Unit test for VerifyCustomerOnboardingAction
VerifyDatabaseCreationActionTest.phpbackend/tests/Unit/app/Actions/Model/CustomerOnboarding/Unit test for VerifyDatabaseCreationAction
VerifyDatabaseMigrationActionTest.phpbackend/tests/Unit/app/Actions/Model/CustomerOnboarding/Unit test for VerifyDatabaseMigrationAction
SaveSettingsDtoTest.phpbackend/tests/Unit/app/DataTransferObjects/Model/CustomerOnboarding/Unit test for SaveSettingsDto
CreateCustomerAdminActionTest.phpbackend/tests/Unit/app/Actions/Model/User/Unit test for CreateCustomerAdminAction
RollbackCustomerAdminCreationActionTest.phpbackend/tests/Unit/app/Actions/Model/User/Unit test for RollbackCustomerAdminCreationAction
SendCustomerAdminInviteActionTest.phpbackend/tests/Unit/app/Actions/Model/User/Unit test for SendCustomerAdminInviteAction
OnboardingControllerTestCase.phpbackend/tests/Feature/Http/Admin/Controllers/OnboardingController/Shared base for all controller feature tests
OnboardingControllerAddressTest.phpbackend/tests/Feature/Http/Admin/Controllers/OnboardingController/Feature test for address()
OnboardingControllerAdminTest.phpbackend/tests/Feature/Http/Admin/Controllers/OnboardingController/Feature test for admin()
OnboardingControllerAuthTest.phpbackend/tests/Feature/Http/Admin/Controllers/OnboardingController/Feature test for auth()
OnboardingControllerBirthdayTest.phpbackend/tests/Feature/Http/Admin/Controllers/OnboardingController/Feature test for birthday()
OnboardingControllerCareTest.phpbackend/tests/Feature/Http/Admin/Controllers/OnboardingController/Feature test for care()
OnboardingControllerGetLoginLinkTest.phpbackend/tests/Feature/Http/Admin/Controllers/OnboardingController/Feature test for getLoginLink()
OnboardingControllerGetOnboardingStepTest.phpbackend/tests/Feature/Http/Admin/Controllers/OnboardingController/Feature test for getOnboardingStep()
OnboardingControllerMdoTest.phpbackend/tests/Feature/Http/Admin/Controllers/OnboardingController/Feature test for mdo()
OnboardingControllerMentorsTypeTest.phpbackend/tests/Feature/Http/Admin/Controllers/OnboardingController/Feature test for mentorsType()
OnboardingControllerOrganizationNameTest.phpbackend/tests/Feature/Http/Admin/Controllers/OnboardingController/Feature test for organizationName()
OnboardingControllerRegisterTest.phpbackend/tests/Feature/Http/Admin/Controllers/OnboardingController/Feature test for register()
OnboardingControllerStoreTest.phpbackend/tests/Feature/Http/Admin/Controllers/OnboardingController/Feature test for store()
OnboardingControllerTerminologyTest.phpbackend/tests/Feature/Http/Admin/Controllers/OnboardingController/Feature test for terminology()
OnboardingControllerUsersTest.phpbackend/tests/Feature/Http/Admin/Controllers/OnboardingController/Feature test for users()
OnboardingControllerWarningTest.phpbackend/tests/Feature/Http/Admin/Controllers/OnboardingController/Feature test for warning()

Missing tests — the following jobs have no test file:

CreateAwsBucket, DeleteAwsBucket, SetupCustomer

Frontend Tests ​

FilenamePathPurpose
AdminForm.spec.tsfrontend/tests/admin/domains/on-boarding/components/Unit test for AdminForm
OnboardingSubmit.spec.tsfrontend/tests/admin/domains/on-boarding/components/Unit test for OnboardingSubmit
OnboardingReviewModal.spec.tsfrontend/tests/admin/domains/on-boarding/components/Unit test for OnboardingReviewModal
Address.spec.tsfrontend/tests/admin/domains/on-boarding/components/steps/Unit test for Address step
UserInvite.spec.tsfrontend/tests/admin/domains/on-boarding/components/steps/Unit test for UserInvite step
state.spec.tsfrontend/tests/admin/domains/on-boarding/Unit test for onboarding form state
OnboardingLogin.spec.tsfrontend/tests/auth/pages/Unit test for post-onboarding login page
onboarding.spec.tsfrontend/tests/e2e/tests/customer/E2E test for the onboarding flow

Missing unit tests — the following step components have no spec file:

GetStarted, Introductory, Organization, OrganizationType, MentorTypes, Terminology, Authentication, Warnings, Birthdays, MDOSettings, Finish, OnboardingForm, OnboardingCreate

Bare minimum onboarding revamp ​

This is a short summary of findings from docs/onboarding/BreakingOnboarding.md. It contains all data we need for the current state of development to work, revised after chaos testing. This summary also assumes there has been no emmie-admin "intervention" that enabled paid modules.


Tier 1: Always seeded automatically — no user input, ever. ​

These must happen unconditionally during SetupCustomerAction. Skipping any of them causes hard crashes or leaves the tenant in a state with no UI recovery path.

WhatWhy it cannot be deferred
Settings rowHard crash on login: No query results for model [Settings]. App is unusable without it.
All 4 mentor types (MentorTypeOnboardingSuggestionEnum)CreateAdminUserAction::firstOrFail() hard crashes without at least one. Caseload access logic breaks for users with no mentor type.
Both care types active (Begeleiding + Dagbesteding)Zero active care types → planning infinite redirect loop. Backend guard already prevents reducing to zero via normal UI post-onboarding, so auto-seeding both and letting admin deactivate one is safe.
Hoofdlocatie (default location, no address required)Zero locations → client activation impossible, user profile edits fail, admin excluded from bulk messages (probably). Location must exist even if the address fields are empty.
Default Dutch terminology (cliënt / cliënten)Terminology edit modal renders an empty table with no add UI when the terminology table is empty. No recovery path without DB intervention.
Introductory meeting email templateEmail template edit UI has no create page — only edit. If not seeded, the template can never be recovered through the UI.
Birthday email templateSame as above. No create UI.
Relation types (moeder, vader, wettelijke vertegenwoordiger, wmo-consultant, persoonlijk begeleider)CreateIntakeContactAction throws RuntimeException: default_intake_contact_relation_type_id setting is not configured on any fresh tenant without them. Hard crash. Relation types are never seeded during onboarding — the platform expects the admin to know they don't exist and add them manually. Oversight.
Care legislationsAbsence causes minor financing quirks. Auto-seed the full list that was already shown as options in Step 7 — no curation, just copy the onboarding constants into the DB.

Tier 2: Must be user input. ​

These cannot be defaulted or derived. The app cannot function as the correct tenant without them.

  • Admin email — unique across all organizations, used for login and the setup email.
  • Admin password — cannot be generated for the user (they need to know it).
  • Admin first name + last name — technically nullable in the DB, but greeting emails render "Beste Onbekend," (or nothing if no default is set) and the name appears throughout the admin UI. Collecting it is non-negotiable in practice.
  • Organization name — drives the tenant domain name (e.g. acmecorp.emmie.nl) and display name. The DB name is always customer{id} regardless. No sensible default exists.

Tier 3: Collect during onboarding, survivable if skipped. ​

These can be skipped without a hard crash. The tenant starts in a mildly degraded state and the admin can complete them post-login in settings or their profile.

  • Admin phone number
  • Organization KvK number — nullable with a unique index on customers. MySQL allows multiple NULLs in a unique index, so organizations that skip it don't conflict with each other. Safe to leave optional.
  • Organization address (the Hoofdlocatie is created regardless — the address just stays empty. It will mess with emails that use the tag {locatie} when there is no address). Important to note is that Hoofdlocatie isn't a "thing" that has context. Just a default name for a location if no name for a location is given.
  • Organization logo but rather not because broken images in emails.

Tier 4: Removed from onboarding entirely. ​

These were onboarding steps that add no shape to the organization and belong in a post-login settings page or guided tour.

Removed stepWhere it belongs instead
Mentor type selection (Step 8)Auto-seed all 4. Admin can rename or deactivate via Instellingen post-login.
Invite colleagues (Step 9)Guided tour or Medewerkers page. Sending invite emails during an already fragile job is unnecessary risk.
Warning intervals (Step 12)Instellingen → Algemene instellingen. Sensible backend defaults apply.
Birthday email content (Step 13)Instellingen → E-mails. The template is seeded (Tier 1); content can be edited post-login.
MDO settings (Step 14)Instellingen → Algemene instellingen. MDO is a subscription module anyway.
2FA / session settings (Step 11)Instellingen → Beveiliging.

Resulting onboarding page structure ​

During onboarding (required):

Page 1 — Admin credentials

  • Email
  • Password

Page 2 — Admin personal info

  • First name
  • Last name

Page 3 — Organization info

  • Organization name
  • KvK number (optional). Unsure about the legalities of this.
  • Logo (optional) but probably shouldn't be because emails will have a broken logo (or we add our own default).
  • Address (optional) but really shouldn't be unless we dealing with this more gracefully.

Page 4 — Submit

Post-login guided setup (optional, shown on first login if not completed):

  • Care type selection (auto-seeded with both active; this step lets admin deactivate types they don't use)
  • Care legislation selection (auto-seeded with defaults; this step lets admin adjust)
  • Client terminology (auto-seeded with Dutch defaults; this step lets admin customize)
  • Admin phone number (if skipped on page 2)
  • 2FA. Should be required for users with a lot of priveledges.

Problems that need fixing regardless of revamp ​

  • UserInvite mail has broken image even if a logo is uploaded due to async behaviour of sending emails.
  • RelationTypes are never seeded during onboarding (they are for script when using php artisan migrate:fresh --seed). Oversight.
  • Admin not linked to Hoofdlocatie — CreateDefaultLocationAction runs after CreateAdminUserAction and never links existing users to the new location. Even with Tier 1 auto-seeding the location, the admin starts with zero locations. Needs to be an explicit step in the revamp: after creating the location, link the admin to it.
  • Authentication.vue sends the entire registrationForm — if 2FA moves to post-login settings, the controller endpoint that receives it currently ingests the whole wizard state as one giant payload. That endpoint needs to be rethought regardless.
  • Transactional emails are hardcoded Dutch — RegisterClientAccount, UserInvite, UserInviteColleague are PHP Mailables with no tenant customization. The revamp seeds customizable templates for birthday and introductory meeting emails, but these operational emails remain hardcoded.
  • CreateCustomer Job will be refactored to use actions (EMMIE-0195). This should be done in together with the entire revamp.
  • onboardingProcess.md will need some corrections. Should be done in tandem with revamp.