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.
OnboardingOrgNameRequestconverts it to anUploadedFileviaConvertBase64ToUploadedFileTrait::prepareForValidation()before the controller runs, so$request->file('logo')works as expected. The logo is stored on the central Emmie S3 bucket viaStorage::store(StoragePathConstant::CUSTOMER_LOGO). - The file size check (5 MB) is done on the raw
Fileobject 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.
ProvidedCareTyperecords 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.
CreateAdminUserActionusesfirstOrFail()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
getrequestformentor_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_typesin 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-rolesreturns 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 theCreateCustomer Integration Testis so brutal. CreateOnboardingUsersActionattaches 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 —CreateOnboardingUsersActionjust 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
registrationFormstate (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
emailContentinstate.tscontains 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. sendEmaildefaults totruein 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, thefailed()method attempts a rollback (drop DB, delete bucket, delete customer record), but this is best-effort.
File notes
Observations about specific files.
config/mail.php + message.blade.php — logo link hardcoded to emmie.nl
The header URL that wraps the logo in every mail::message email is hardcoded in config/mail.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:
$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:
CreateAdminUserActioncallsMentorType::firstOrFail()— requires Step 8 to have saved at least one mentor type.CreateDefaultLocationuses the address saved in Step 6.ContactClientRelationType'persoonlijk begeleider' must exist post-migration (independent of any step, but a hidden prerequisite).Rolerecords 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.ClientControllerhas anotherfirstOrFail()on mentor type when displaying a client's mentor details.
Logic breakage (no crash but wrong behaviour):
User::canAccessOutsideCaseload()returnsfalseif 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.
| Group | Files |
|---|---|
| Frontend — Page & Shell | 5 |
| Frontend — Step Components | 13 |
| Frontend — Shared | 5 |
| Backend — Routes | 1 |
| Backend — Controller | 3 |
| Backend — Jobs | 5 |
| Backend — Actions (CustomerOnboarding) | 12 |
| Backend — Actions (Other) | 4 |
| Backend — Services | 1 |
| Backend — Form Requests | 13 |
| Backend — Mails | 5 |
| Backend — Central Models | 4 |
| Backend — Tenant Models | 6 |
| Backend — DTOs | 7 |
| Backend — Interfaces | 6 |
| Backend — Enums & Traits | 5 |
| Backend Tests | 35 |
| Frontend Tests | 8 |
| Total | 138 |
Frontend — Page & Shell
| Filename | Path | Purpose |
|---|---|---|
OnboardingCreate.vue | frontend/apps/admin/domains/on-boarding/pages/ | Outer wrapper; hosts all wizard steps |
OnboardingForm.vue | frontend/apps/admin/domains/on-boarding/components/ | Shared form shell (back/next buttons) |
OnboardingSubmit.vue | frontend/apps/admin/domains/on-boarding/components/ | Final POST + polls for job completion |
OnboardingReviewModal.vue | frontend/apps/admin/domains/on-boarding/components/ | Review modal shown on Step 15 before submitting |
AdminForm.vue | frontend/apps/admin/domains/on-boarding/components/ | Step 2: email + password entry |
Frontend — Step Components
| Filename | Path | Purpose |
|---|---|---|
GetStarted.vue | frontend/apps/admin/domains/on-boarding/components/steps/ | Step 3: intro screen, no backend call |
Introductory.vue | frontend/apps/admin/domains/on-boarding/components/steps/ | Step 4: admin personal info |
Organization.vue | frontend/apps/admin/domains/on-boarding/components/steps/ | Step 5: org name, KvK, logo |
Address.vue | frontend/apps/admin/domains/on-boarding/components/steps/ | Step 6: organization address |
OrganizationType.vue | frontend/apps/admin/domains/on-boarding/components/steps/ | Step 7: care legislations + care types |
MentorTypes.vue | frontend/apps/admin/domains/on-boarding/components/steps/ | Step 8: begeleider types |
UserInvite.vue | frontend/apps/admin/domains/on-boarding/components/steps/ | Step 9: invite colleagues |
Terminology.vue | frontend/apps/admin/domains/on-boarding/components/steps/ | Step 10: client terminology |
Authentication.vue | frontend/apps/admin/domains/on-boarding/components/steps/ | Step 11: 2FA + session settings |
Warnings.vue | frontend/apps/admin/domains/on-boarding/components/steps/ | Step 12: warning intervals |
Birthdays.vue | frontend/apps/admin/domains/on-boarding/components/steps/ | Step 13: birthday email settings |
MDOSettings.vue | frontend/apps/admin/domains/on-boarding/components/steps/ | Step 14: MDO settings |
Finish.vue | frontend/apps/admin/domains/on-boarding/components/steps/ | Step 15: review + trigger creation |
Frontend — Shared
| Filename | Path | Purpose |
|---|---|---|
AddressFormColumn.vue | frontend/apps/shared/components/address/ | Address form with Pro6pp autocomplete |
useAddressLookup.ts | frontend/apps/shared/composables/ | Calls address API, auto-fills street + city |
state.ts | frontend/apps/admin/domains/on-boarding/ | Reactive form state for the whole wizard |
constants.ts | frontend/apps/admin/domains/on-boarding/ | Hardcoded care types, legislations, mentor types |
OnboardingLogin.vue | frontend/apps/auth/pages/ | Login page the admin lands on after org creation |
Backend — Routes
| Filename | Path | Purpose |
|---|---|---|
admin.php | backend/routes/ | All onboarding endpoints (16 routes) + onboarding-roles via RoleController and address via AddressController |
Backend — Controller
| Filename | Path | Purpose |
|---|---|---|
OnboardingController.php | backend/app/Http/Admin/Controllers/ | All 13 wizard POST endpoints + store + polling |
RoleController.php | backend/app/Http/Admin/Controllers/ | Provides role list for step 9 (user invite) via onboarding-roles |
AddressController.php | backend/app/Http/Controllers/ | Pro6pp address lookup (zipcode → street + city) for step 6 |
Backend — Jobs
| Filename | Path | Purpose |
|---|---|---|
CreateCustomer.php | backend/app/Jobs/ | Orchestrates DB creation, migration, bucket, and setup |
CreateAwsBucket.php | backend/app/Jobs/ | Creates S3/MinIO bucket for the new tenant |
DeleteAwsBucket.php | backend/app/Jobs/ | Deletes the bucket during rollback in failed() |
SetupCustomer.php | backend/app/Jobs/ | Delegates to OnboardCustomerAction; falls back to CreateCustomerAdmin |
CreateCustomerAdmin.php | backend/app/Jobs/ | Fallback: creates admin user + sends invite when setup was already done |
Backend — Actions (CustomerOnboarding)
| Filename | Path | Purpose |
|---|---|---|
OnboardCustomerAction.php | backend/app/Actions/Model/CustomerOnboarding/ | Orchestrator: runs SetupCustomerAction + sends welcome/invite emails |
SetupCustomerAction.php | backend/app/Actions/Model/CustomerOnboarding/ | Populates new tenant DB from onboarding data |
SaveTerminologyAction.php | backend/app/Actions/Model/CustomerOnboarding/ | Saves client terminology (non-critical) |
SaveSettingsAction.php | backend/app/Actions/Model/CustomerOnboarding/ | Saves auth + session settings (non-critical) |
SaveOnboardingTypesAction.php | backend/app/Actions/Model/CustomerOnboarding/ | Saves care types, legislations, mentor types (critical) |
CreateAdminUserAction.php | backend/app/Actions/Model/CustomerOnboarding/ | Creates the admin user (critical) |
CreateOnboardingUsersAction.php | backend/app/Actions/Model/CustomerOnboarding/ | Creates invited colleagues (non-critical) |
VerifyDatabaseCreationAction.php | backend/app/Actions/Model/CustomerOnboarding/ | Sanity check: DB was created |
VerifyDatabaseMigrationAction.php | backend/app/Actions/Model/CustomerOnboarding/ | Sanity check: migrations ran |
VerifyBucketCreationAction.php | backend/app/Actions/Model/CustomerOnboarding/ | Sanity check: S3 bucket exists |
VerifyCustomerOnboardingAction.php | backend/app/Actions/Model/CustomerOnboarding/ | Sanity check: customer is onboarded |
CleanupOnboardingAction.php | backend/app/Actions/Model/CustomerOnboarding/ | Deletes onboarding + onboarding_users records |
Backend — Actions (Other)
| Filename | Path | Purpose |
|---|---|---|
CreateDefaultLocationAction.php | backend/app/Actions/Model/Location/ | Creates 'Hoofdlocatie' from onboarding address |
CreateCustomerAdminAction.php | backend/app/Actions/Model/User/ | Creates the admin user in the tenant DB (fallback path via CreateCustomerAdmin job) |
SendCustomerAdminInviteAction.php | backend/app/Actions/Model/User/ | Sends invite email to the new admin (fallback path) |
RollbackCustomerAdminCreationAction.php | backend/app/Actions/Model/User/ | Rolls back admin user creation if CreateCustomerAdmin job fails |
Backend — Services
| Filename | Path | Purpose |
|---|---|---|
Pro6ppService.php | backend/app/Services/Location/ | Address lookup via Pro6pp API |
Backend — Form Requests
| Filename | Path | Purpose |
|---|---|---|
OnboardingRequest.php | backend/app/Http/Admin/Requests/OnboardingRequests/ | Base request class |
OnboardingRegisterRequest.php | backend/app/Http/Admin/Requests/OnboardingRequests/ | Step 2: email + password |
OnboardingAdminRequest.php | backend/app/Http/Admin/Requests/OnboardingRequests/ | Step 4: admin personal info |
OnboardingOrgNameRequest.php | backend/app/Http/Admin/Requests/OnboardingRequests/ | Step 5: name, KvK, logo + base64 conversion |
OnboardingAddressRequest.php | backend/app/Http/Admin/Requests/OnboardingRequests/ | Step 6: address (format-only validation) |
OnboardingCareRequest.php | backend/app/Http/Admin/Requests/OnboardingRequests/ | Step 7: care types + legislations |
OnboardingMentorsTypeRequest.php | backend/app/Http/Admin/Requests/OnboardingRequests/ | Step 8: mentor type IDs |
OnboardingUsersRequest.php | backend/app/Http/Admin/Requests/OnboardingRequests/ | Step 9: invited users |
OnboardingTerminologyRequest.php | backend/app/Http/Admin/Requests/OnboardingRequests/ | Step 10: terminology |
OnboardingAuthRequest.php | backend/app/Http/Admin/Requests/OnboardingRequests/ | Step 11: auth settings (receives entire form) |
OnboardingWarningRequest.php | backend/app/Http/Admin/Requests/OnboardingRequests/ | Step 12: warning intervals |
OnboardingBirthdaySettingsRequest.php | backend/app/Http/Admin/Requests/OnboardingRequests/ | Step 13: birthday email |
OnboardingMDOSettingsRequest.php | backend/app/Http/Admin/Requests/OnboardingRequests/ | Step 14: MDO settings |
Backend — Mails
| Filename | Path | Purpose |
|---|---|---|
MailFromNoReply.php | backend/app/Mail/ | Base mailer class |
CustomerSetup.php | backend/app/Mail/ | Welcome email to admin of new org |
CustomerSetup.blade.php | backend/resources/views/emails/ | Blade template for CustomerSetup mail |
UserInvite.php | backend/app/Mail/ | Invite email to colleagues added during onboarding |
UserInvite.blade.php | backend/resources/views/emails/ | Blade template for UserInvite mail |
Backend — Central Models
| Filename | Path | Purpose |
|---|---|---|
Onboarding.php | backend/app/Models/Admin/ | Central DB onboarding record |
OnboardingUser.php | backend/app/Models/Admin/ | Central DB record for each invited colleague during onboarding |
Customer.php | backend/app/Models/Admin/ | Central DB customer record (holds onboarding_step) |
Role.php | backend/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.
| Filename | Path | Purpose |
|---|---|---|
CareLegislation.php | backend/app/Models/Customer/ | Stores selected care legislation frameworks |
Email.php | backend/app/Models/Customer/ | Stores birthday + introductory meeting email templates |
MentorType.php | backend/app/Models/Customer/ | Stores begeleider types with forced IDs |
ProvidedCareType.php | backend/app/Models/Customer/ | Activated care types (must pre-exist via migration) |
Settings.php | backend/app/Models/Customer/ | Stores auth, session, warning, MDO settings |
Terminology.php | backend/app/Models/Customer/ | Stores client terminology (singular/plural) |
Backend — Interfaces
| Filename | Path | Purpose |
|---|---|---|
CreateAdminUserInterface.php | backend/app/Interfaces/Model/CustomerOnboarding/ | Contract for CreateAdminUserAction |
CreateOnboardingUsersInterface.php | backend/app/Interfaces/Model/CustomerOnboarding/ | Contract for CreateOnboardingUsersAction |
OnboardingUserInterface.php | backend/app/Interfaces/Model/CustomerOnboarding/ | Contract for a single onboarding user |
SaveOnboardingTypesInterface.php | backend/app/Interfaces/Model/CustomerOnboarding/ | Contract for SaveOnboardingTypesAction |
SaveSettingsInterface.php | backend/app/Interfaces/Model/CustomerOnboarding/ | Contract for SaveSettingsAction |
SaveTerminologyInterface.php | backend/app/Interfaces/Model/CustomerOnboarding/ | Contract for SaveTerminologyAction |
Backend — DTOs
| Filename | Path | Purpose |
|---|---|---|
CreateAdminUserDto.php | backend/app/DataTransferObjects/Model/CustomerOnboarding/ | Input for CreateAdminUserAction |
CreateOnboardingUsersDto.php | backend/app/DataTransferObjects/Model/CustomerOnboarding/ | Input for CreateOnboardingUsersAction |
OnboardingUserDto.php | backend/app/DataTransferObjects/Model/CustomerOnboarding/ | Single invited user within CreateOnboardingUsersDto |
SaveOnboardingTypesDto.php | backend/app/DataTransferObjects/Model/CustomerOnboarding/ | Input for SaveOnboardingTypesAction |
SaveSettingsDto.php | backend/app/DataTransferObjects/Model/CustomerOnboarding/ | Input for SaveSettingsAction |
SaveTerminologyDto.php | backend/app/DataTransferObjects/Model/CustomerOnboarding/ | Input for SaveTerminologyAction |
SetupCustomerResultDto.php | backend/app/DataTransferObjects/Model/CustomerOnboarding/ | Return value of SetupCustomerAction |
Backend — Enums & Traits
| Filename | Path | Purpose |
|---|---|---|
OnboardingStepEnum.php | backend/app/Enums/ | Pipeline stage tracker (DATABASE_SETUP → FINISHED) |
MentorTypeOnboardingSuggestionEnum.php | backend/app/Enums/ | 4 hardcoded mentor type IDs |
EmailNameEnum.php | backend/app/Enums/ | Names for email templates (birthday, introductory meeting) |
TerminologyEnum.php | backend/app/Enums/ | Sanity check key for terminology persistence |
ConvertBase64ToUploadedFileTrait.php | backend/app/Http/Requests/Traits/ | Converts base64 logo to UploadedFile pre-validation |
Backend Tests
| Filename | Path | Purpose |
|---|---|---|
CreateCustomerTest.php | backend/tests/Integration/app/Jobs/CreateCustomer/ | Integration test: full job + all rollback scenarios |
CreateCustomerAdminTest.php | backend/tests/Integration/app/Jobs/CreateCustomerAdmin/ | Integration test for CreateCustomerAdmin job |
SetupCustomerActionTest.php | backend/tests/Integration/app/Actions/Model/CustomerOnboarding/ | Integration test for SetupCustomerAction |
CleanupOnboardingActionTest.php | backend/tests/Unit/app/Actions/Model/CustomerOnboarding/ | Unit test for CleanupOnboardingAction |
CreateAdminUserActionTest.php | backend/tests/Unit/app/Actions/Model/CustomerOnboarding/ | Unit test for CreateAdminUserAction |
CreateOnboardingUsersActionTest.php | backend/tests/Unit/app/Actions/Model/CustomerOnboarding/ | Unit test for CreateOnboardingUsersAction |
OnboardCustomerActionTest.php | backend/tests/Unit/app/Actions/Model/CustomerOnboarding/ | Unit test for OnboardCustomerAction |
SaveOnboardingTypesActionTest.php | backend/tests/Unit/app/Actions/Model/CustomerOnboarding/ | Unit test for SaveOnboardingTypesAction |
SaveSettingsActionTest.php | backend/tests/Unit/app/Actions/Model/CustomerOnboarding/ | Unit test for SaveSettingsAction |
SaveTerminologyActionTest.php | backend/tests/Unit/app/Actions/Model/CustomerOnboarding/ | Unit test for SaveTerminologyAction |
SetupCustomerActionTest.php | backend/tests/Unit/app/Actions/Model/CustomerOnboarding/ | Unit test for SetupCustomerAction |
VerifyBucketCreationActionTest.php | backend/tests/Unit/app/Actions/Model/CustomerOnboarding/ | Unit test for VerifyBucketCreationAction |
VerifyCustomerOnboardingActionTest.php | backend/tests/Unit/app/Actions/Model/CustomerOnboarding/ | Unit test for VerifyCustomerOnboardingAction |
VerifyDatabaseCreationActionTest.php | backend/tests/Unit/app/Actions/Model/CustomerOnboarding/ | Unit test for VerifyDatabaseCreationAction |
VerifyDatabaseMigrationActionTest.php | backend/tests/Unit/app/Actions/Model/CustomerOnboarding/ | Unit test for VerifyDatabaseMigrationAction |
SaveSettingsDtoTest.php | backend/tests/Unit/app/DataTransferObjects/Model/CustomerOnboarding/ | Unit test for SaveSettingsDto |
CreateCustomerAdminActionTest.php | backend/tests/Unit/app/Actions/Model/User/ | Unit test for CreateCustomerAdminAction |
RollbackCustomerAdminCreationActionTest.php | backend/tests/Unit/app/Actions/Model/User/ | Unit test for RollbackCustomerAdminCreationAction |
SendCustomerAdminInviteActionTest.php | backend/tests/Unit/app/Actions/Model/User/ | Unit test for SendCustomerAdminInviteAction |
OnboardingControllerTestCase.php | backend/tests/Feature/Http/Admin/Controllers/OnboardingController/ | Shared base for all controller feature tests |
OnboardingControllerAddressTest.php | backend/tests/Feature/Http/Admin/Controllers/OnboardingController/ | Feature test for address() |
OnboardingControllerAdminTest.php | backend/tests/Feature/Http/Admin/Controllers/OnboardingController/ | Feature test for admin() |
OnboardingControllerAuthTest.php | backend/tests/Feature/Http/Admin/Controllers/OnboardingController/ | Feature test for auth() |
OnboardingControllerBirthdayTest.php | backend/tests/Feature/Http/Admin/Controllers/OnboardingController/ | Feature test for birthday() |
OnboardingControllerCareTest.php | backend/tests/Feature/Http/Admin/Controllers/OnboardingController/ | Feature test for care() |
OnboardingControllerGetLoginLinkTest.php | backend/tests/Feature/Http/Admin/Controllers/OnboardingController/ | Feature test for getLoginLink() |
OnboardingControllerGetOnboardingStepTest.php | backend/tests/Feature/Http/Admin/Controllers/OnboardingController/ | Feature test for getOnboardingStep() |
OnboardingControllerMdoTest.php | backend/tests/Feature/Http/Admin/Controllers/OnboardingController/ | Feature test for mdo() |
OnboardingControllerMentorsTypeTest.php | backend/tests/Feature/Http/Admin/Controllers/OnboardingController/ | Feature test for mentorsType() |
OnboardingControllerOrganizationNameTest.php | backend/tests/Feature/Http/Admin/Controllers/OnboardingController/ | Feature test for organizationName() |
OnboardingControllerRegisterTest.php | backend/tests/Feature/Http/Admin/Controllers/OnboardingController/ | Feature test for register() |
OnboardingControllerStoreTest.php | backend/tests/Feature/Http/Admin/Controllers/OnboardingController/ | Feature test for store() |
OnboardingControllerTerminologyTest.php | backend/tests/Feature/Http/Admin/Controllers/OnboardingController/ | Feature test for terminology() |
OnboardingControllerUsersTest.php | backend/tests/Feature/Http/Admin/Controllers/OnboardingController/ | Feature test for users() |
OnboardingControllerWarningTest.php | backend/tests/Feature/Http/Admin/Controllers/OnboardingController/ | Feature test for warning() |
Missing tests — the following jobs have no test file:
CreateAwsBucket, DeleteAwsBucket, SetupCustomer
Frontend Tests
| Filename | Path | Purpose |
|---|---|---|
AdminForm.spec.ts | frontend/tests/admin/domains/on-boarding/components/ | Unit test for AdminForm |
OnboardingSubmit.spec.ts | frontend/tests/admin/domains/on-boarding/components/ | Unit test for OnboardingSubmit |
OnboardingReviewModal.spec.ts | frontend/tests/admin/domains/on-boarding/components/ | Unit test for OnboardingReviewModal |
Address.spec.ts | frontend/tests/admin/domains/on-boarding/components/steps/ | Unit test for Address step |
UserInvite.spec.ts | frontend/tests/admin/domains/on-boarding/components/steps/ | Unit test for UserInvite step |
state.spec.ts | frontend/tests/admin/domains/on-boarding/ | Unit test for onboarding form state |
OnboardingLogin.spec.ts | frontend/tests/auth/pages/ | Unit test for post-onboarding login page |
onboarding.spec.ts | frontend/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.
| What | Why it cannot be deferred |
|---|---|
Settings row | Hard 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 template | Email template edit UI has no create page — only edit. If not seeded, the template can never be recovered through the UI. |
| Birthday email template | Same 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 legislations | Absence 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 alwayscustomer{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 —
nullablewith a unique index oncustomers. 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
Hoofdlocatieis 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 thatHoofdlocatieisn'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 step | Where 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
- 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 mailhas broken image even if a logo is uploaded due to async behaviour of sending emails.RelationTypesare never seeded during onboarding (they are for script when usingphp artisan migrate:fresh --seed). Oversight.- Admin not linked to Hoofdlocatie —
CreateDefaultLocationActionruns afterCreateAdminUserActionand 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.vuesends 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.mdwill need some corrections. Should be done in tandem with revamp.