Onboarding โ response
Reply to onboarding-api-request.md (2026-09-14).
Read ยง3 first โ the four collections were being dropped during validation and
never written anywhere. That is fixed, but the rows already lost are not
recoverable.
- Base URL:
/api/v1ยท Auth: Sanctum bearer token,2fa.completed - Full references: onboarding-api.md ยท profile-api-migration.md
| Marker | Meaning |
|---|---|
| โ | Shipped this round |
| โ | A deviation from your spec, or a client action needed |
| ๐ฌ | An answer to a question, not a build |
| ยง | Your ask | Status |
|---|---|---|
| ยง1 | What validates interests? An endpoint for the list |
๐ฌ Free text, 3โ10 ยท โ
GET /interests shipped ยท store labels |
| ยง2 | Expertise catalogue is mentor-gated | ๐ฌ It is not โ no role gate ยท โ
GET /public/expertise-areas added anyway |
| ยง3 | Does POST /onboarding persist the 5 keys? |
โ No โ four of them were silently dropped. โ Fixed |
| ยง4 | Widen social_links to 11 platforms |
โ Shipped โ and the read side had the same 4-key limit, ยง4 |
| ยง5 | Are how_did_you_hear / expectations required? |
๐ฌ Both optional. So is every other questionnaire key |
| ยง6 | Is a mentee submission with only interests accepted? |
๐ฌ Yes, covered by a test |
| ยง7 | Can a brand new mentor upload documents immediately? | ๐ฌ Yes โ the check reads the database |
| ยง8 | Mirror the client-side rules | โ Shipped, with two deliberate differences โ ยง8 |
Nothing in your document is blocked. Tests live in
tests/Feature/Auth/ProfileAndOnboardingTest.php,
tests/Feature/Auth/UserCredentialsTest.php,
tests/Feature/Auth/InterestCatalogueTest.php and
tests/Feature/Auth/ExpertiseAreaCatalogueTest.php.
ยง3. POST /onboarding was dropping four of the five collections โ
Confirmed, and worse than a service that ignores them: they never reached the
service at all. responses is validated as an array with validated children,
and Laravel's excludeUnvalidatedArrayKeys (on by default) strips every child
of such an array that has no rule of its own. Only interests had one.
POST body responses{ interests, expertise_areas, experiences, education, certifications }
validated() responses{ interests }
So expertise_areas, experiences, education and certifications were
discarded before OnboardingService ran. They were not written to the user's
rows and not kept in onboarding_questionnaires.responses โ there is no
copy anywhere to backfill from.
This is older than the profile migration: responses has had validated children
since the endpoint was written, so every submission that ever carried those
four keys lost them, not only the ones since the migration. The only recovery is
asking those users to re-enter the data on the profile screen.
Fixed. POST /onboarding now validates all five keys and writes the four
collections through UserCredentialService โ the same code path PUT /profile
uses โ inside the transaction that grants the mentor role. The questionnaire
keeps its copy, as it already did for interests.
Semantics match PUT /profile exactly:
- A key that is present is the complete list. Rows with an
idare updated, rows without one are created, anything omitted is deleted. - A key that is absent is untouched. A questionnaire-only submission does not wipe collections saved by an earlier per-step save. There is a test for this precisely because it is the dangerous case.
expertise_areas[].idis the catalogue id andskills[]are skill ids, both checked against the catalogue.- Every
idis scoped to the caller's own rows โ naming another user's certification is a422, not an overwrite.
Validation errors are keyed under the nested path:
{ "status": "error", "message": "Validation failed",
"data": { "responses.expertise_areas.0.id": ["The selected โฆ is invalid."] } }
Action for you: you can ship the per-step saves as a normal follow-up rather
than a hotfix. Both paths work and write the same rows, so moving a step from
responses to PUT /profile is safe to do one step at a time.
ยง1. Interests: free text, 3โ10, and now with a catalogue โ ๐ฌ
PUT /profile does not check interests against anything. The rules are
array|min:3|max:10, each entry string|max:100. Your 52 hardcoded strings
cannot 422 on content, only on count. users.interests is a JSON column of
plain strings.
Store the label. name is what the column holds and what GET /me returns;
slugs are for your own keys, not for us.
GET /api/v1/interests โ public, no token, exactly the shape you sketched:
{
"status": "success",
"message": "Interests retrieved successfully.",
"data": { "groups": [
{ "label": "Design", "interests": [
{ "id": "9b1fโฆ", "slug": "ux-design", "name": "UX design" }
]}
]}
}
Groups come back in catalogue order: Design, Engineering, Product and business,
Marketing and content, Creative, Career and life โ 52 entries, seeded by
Database\Seeders\Interest\InterestSeeder. It is part of db:seed and can be
run on its own โ php artisan db:seed --class="Database\Seeders\Interest\InterestSeeder" โ
which is what a deploy needs; it is idempotent on the slug, so re-running it
updates rather than duplicates. Until it has run in an environment, groups is
[] rather than an error, so keep your list as the fallback for one release.
Validation deliberately stays free text. A closed in: rule would mean every
user who onboarded before the catalogue existed gets a 422 the next time they
save an unrelated profile field, and it would make adding an interest a
deploy. The catalogue is the suggested vocabulary; the column accepts what you
send.
โ One thing to check: we do not have your interests.ts, so the 52 entries
are our rendering of your six group names, not a copy of your list. Send the
file (or diff it against GET /interests) and we will align the seed to it โ
since storage is free text, a mismatch costs nothing until then, but the two
should agree before you delete the hardcoded list.
ยง2. The expertise catalogue was never mentor-gated ๐ฌโ
GET /mentor/available-expertise-areas sits in a group with auth:sanctum
only โ no role:mentor, no mentor-profile lookup. The controller returns a
global catalogue. A mentee has always received a 200 here; if step 4 renders
an empty picker, the cause is elsewhere (an empty catalogue in that environment,
or the response shape โ the areas are at data.expertise_areas).
Added anyway, because a mentee calling a /mentor/ URL reads like a bug even
when it is not:
GET /api/v1/public/expertise-areas no token required, same payload
Both routes stay. Point step 4 at whichever you prefer.
ยง4. social_links: 11 platforms, and the read side too โ
Widened to exactly your list โ linkedin, x, instagram, facebook, github, youtube, dribbble, behance, medium, twitch, website โ full URLs, max:255,
null to clear.
โ The part you could not see from your side: validation was not the only 4-key limit. Every read surface resolves socials through one helper, which returned exactly those four keys regardless of what was stored. Widening the rules alone would have saved a GitHub link and still never shown it on prefill. Both halves are now driven by the same list, so a platform that can be written can always be read back.
GET /me, GET /user/{username} and the chat participant card now return all
11 keys, nulls included, in the order above. twitter remains accepted on input
and always comes back as x.
One shape difference worth knowing, unchanged by this round: the data.user
echoed by PUT /profile is the raw stored map โ only the keys that have a
value โ while /me and the profile endpoints emit the normalised 11. Prefill
from /me, not from the save response, if you want the full set of nulls.
ยง5 and ยง6. Nothing in the questionnaire is required ๐ฌ
how_did_you_hear and expectations are required: false โ both optional,
confirmed. Hiding them cannot 422 the submit.
Going further than you asked: no questionnaire key is required server-side.
The six you render are all sometimes too, so a mentor submit missing any of
them is accepted. The "required": true flags in
GET /onboarding/questions/{role} describe the intended UI, not an enforced
rule; treat them as advisory. Tell us if you would rather they were enforced โ
that is a one-line change per field, but it would turn a hidden step into a hard
failure, so we have left it to you.
ยง6 โ a mentee submission of { "role": "mentee", "responses": { "interests": [โฆ] } } is accepted, and there is a test asserting exactly that payload.
ยง7. A brand new mentor can upload immediately ๐ฌ
The role check reads the database, not a claim on the token. Sanctum tokens
carry no roles, and POST /mentor/verification-documents does not even check
the role โ it needs the mentor profile, which POST /onboarding creates in
the same transaction that grants the role. The token issued before the grant
keeps working.
There is a test for the exact sequence: onboard as mentor, then upload with the pre-existing token.
Unchanged: is_onboarded flips to true at POST /onboarding (it is computed
from the completed questionnaire), and role: mentor grants the mentor role
additively โ the mentee role is kept.
ยง8. Server-side rules, now mirrored โ โ
| Field | Your rule | Server | Note |
|---|---|---|---|
date_of_birth |
past, age 13โ120 | โ same | 422 outside the window |
phone_number |
E.164 with + |
โ now accepted at all | see below |
bio |
โค 500 | โ โค 2000 | deliberate |
full_name |
2โ60 | โ 2โ255 | deliberate |
interests |
โฅ 3 | โ 3โ10 | already enforced |
โ phone_number was not accepted by PUT /profile at all. It had no rule,
so it was dropped exactly like the extra social links โ step 1 has been sending
it into nothing. It now writes to the user.
- Format:
+then a non-zero digit then 6โ14 more. The+is optional on input, for compatibility with the clients already posting bare numbers to/phone/update; the value is stored exactly as sent, so send it with the+and it round-trips unchanged. - Writing it here does not send a verification SMS.
POST /phone/updateremains the verification flow. - Changing the number clears
phone_verified_at. Re-sending the same number leaves it alone, so a step-1 save that does not touch the phone will not un-verify anyone.
bio stays at 2000 and full_name at 255 โ a server that is looser than
your form is not a bug, and tightening them would reject profiles that already
exist the next time their owner saves any unrelated field. Keep enforcing 500
and 60 in the wizard; we will not accept less than 2 characters of a name.
Appendix: what changed, by endpoint
GET /interests NEW โ public, grouped catalogue
GET /public/expertise-areas NEW โ public alias of the mentor route
PUT /profile + phone_number (was silently dropped)
+ social_links: 11 keys (was 4)
+ date_of_birth: age 13โ120
+ full_name: min 2
POST /onboarding + responses.{certifications, education,
experiences, expertise_areas} now
validated AND persisted (were dropped)
GET /me, /user/{username}, chat cards social_links now carries 11 keys
Unchanged: the envelope, snake_case, GET /mentor/available-expertise-areas,
POST /mentor/verification-documents, and the is_onboarded / mentor-role
behaviour you mirror locally.