useLukkForm
Most of what a Laravel app does is submit a form and show the validation errors it gets back. useLukkForm is the client for exactly that: hold the fields, submit them to your API, and bind a Laravel 422 bag onto per-field errors — over useLukkFetch, so it is authenticated, SSR-correct, and identical in either transport mode. It is modelled closely on Inertia's useForm, so it should feel familiar.
useLukkForm
const form = useLukkForm(initialData, options?)useLukkForm returns a single reactive object. The fields live under form.data (not spread onto the form itself), so a field may safely be named errors, processing, or submit. Every useLukkForm() call is an independent form.
IMPORTANT
URLs resolve against your app's API base, not lukk's auth base — api.target in direct mode, the proxy mount in bff mode. form.post('/register') reaches your /register, not lukk's POST /auth/register.
That's the intended split: useLukkForm and useLukkFetch are for endpoints you own; lukk's own auth endpoints are reached through the composables (useLukkAuth, useLukkChangePassword, and friends), which hold the right base. To show a lukk 422 on your fields, catch the LukkError and read its errors bag — see Change Password for the pattern.
const form = useLukkForm({ email: '', password: '' })
form.data // { email, password } — the live, editable fields
form.errors // { email?: string, password?: string } — first message per field
form.processing // boolean — a submit is in flight
form.hasErrors // boolean
form.isDirty // boolean — data differs from the defaults
form.wasSuccessful // boolean — the last submit succeeded
form.recentlySuccessful // boolean — true briefly after success (for a "Saved!" flash)The optional second argument configures the form:
useLukkForm({ … }, {
recentlySuccessfulMs: 2000, // how long recentlySuccessful stays true
rememberKey: 'signup', // persist form.data across SPA navigation (Nuxt useState)
})With rememberKey, a half-filled form survives a route change and back — mount another form with the same key and its data is restored. (The reset/isDirty baseline isn't remembered; isDirty compares the restored data against the original initial.) Use it for plain drafts: the state is serialized into the SSR payload, so don't remember File/Blob fields or sensitive values.
The draft is stored under the Nuxt state key lukk:form:<rememberKey>, so it can't collide with the rest of your app's state (lukk-nuxt 0.12.0). Earlier releases used rememberKey itself as the key, which meant rememberKey: 'lukk:user' overwrote the signed-in user. A draft remembered under the old key isn't carried over.
The value type flows through, so form.data.email is typed, and the returned form is a LukkForm<T>.
Basic Usage
Bind the fields with v-model="form.data.*", submit with a verb, and read form.errors in the template:
<script setup lang="ts">
const form = useLukkForm({ email: '', password: '' })
async function register() {
try {
await form.post('/register') // YOUR route — see the note above
await navigateTo('/dashboard')
}
catch {
// The 422 is already bound to form.errors; other failures rethrow to your handler.
}
}
</script>
<template>
<form @submit.prevent="register">
<input v-model="form.data.email" type="email">
<small v-if="form.errors.email">{{ form.errors.email }}</small>
<input v-model="form.data.password" type="password">
<small v-if="form.errors.password">{{ form.errors.password }}</small>
<button :disabled="form.processing">Create account</button>
</form>
</template>That is the whole loop — no manual error plumbing, no Accept headers, no bearer handling.
Validation Errors
When a submit fails with a Laravel validation bag — a 422, or any other rejection whose body carries an errors object — lukk maps it onto form.errors, keyed by field. Laravel returns an array of messages per field; form.errors surfaces the first one (the same choice Inertia makes):
// Laravel's 422 body
{ "message": "…", "errors": { "email": ["The email has already been taken."] } }form.errors.email // "The email has already been taken."
form.hasErrors // trueEvery submit clears the errors first, then re-populates them only from the rejection's errors bag (whatever its status) — so a stale message never lingers into the next attempt. A field with no message, and a field named __proto__ or constructor, is skipped.
NOTE
Nested & array fields. Laravel flattens the bag into dot notation — nested objects as authorization.role, array items as users.0.email. form.errors keys them exactly as returned, so read them with bracket access: form.errors['authorization.role']. For a nested form.data, use form.nestedErrors — the same errors expanded into a nested object: bind form.nestedErrors.authorization?.role, and array fields nest under their index, form.nestedErrors.users?.[0]?.email.
Both are typed (lukk-nuxt 0.12.0). form.errors takes a field of data or any dotted path (`${string}.${string}`), so form.errors['authorization.role'] type-checks, and setError / clearErrors accept the same keys. form.nestedErrors follows the shape of data: an object field nests, an array field is indexed, and a leaf — a scalar, a Date, a File/Blob — is a string. A nested field's own message (Laravel can report items as well as items.0.name) is in form.errors.items, not in nestedErrors. Earlier releases typed errors with the top-level fields only and nestedErrors as Record<string, unknown>, so both of the reads above failed to type-check.
You can also drive errors yourself (e.g. from a client-side check). All of these are chainable (they return the form):
form.setError('email', 'That address looks off.')
form.setError({ email: 'Required.', password: 'Too short.' }) // set several at once
form.clearErrors('email') // clear one (or several) fields
form.clearErrors() // clear allSubmitting
There is a method per verb, plus a generic submit:
form.post('/register')
form.put(`/posts/${id}`)
form.patch(`/users/${id}`)
form.delete(`/posts/${id}`)
form.get('/search') // sends the fields as the query string
form.submit('post', '/register') // the generic formFor every verb except get, form.data is sent as the request body; get sends the (flat) fields as the query string.
Each call returns a promise of the parsed response body, and on an HTTP error rejects with a typed LukkError ({ status, message, errors?, reason?, retryAfter? }) — so you can await the result, or try/catch and branch on status. Two rejections are not LukkErrors: a cancelled submit rejects with the AbortError itself, and a network failure with ofetch's FetchError.
const user = await form.post<User>('/register') // typed via the generic
try {
await form.put(`/posts/${id}`)
}
catch (e) {
if ((e as LukkError).status === 403) { /* … */ }
}The last argument accepts per-submit ofetch options (headers, signal, …) alongside the lifecycle hooks below:
form.post('/posts', { headers: { 'X-Idempotency-Key': key } })Lifecycle Hooks
Pass onSuccess, onError, and onFinish in the submit options. They run in addition to the returned promise, and mirror Inertia's callbacks:
form.post<Post>('/posts', {
onSuccess: (post) => navigateTo(`/posts/${post.id}`), // `post` is a Post
onError: (error) => console.warn(error.message),
onFinish: () => { /* runs on success AND failure — like `finally` */ },
})onSuccess(result)— the parsed response body, typed by the verb's generic (form.post<Post>(…)); without one it isunknown.onError(error)— what the submit rejects with (also rethrown to yourawait/catch): typed as aLukkError, though a cancelled or network-failed submit hands it theAbortErrororFetchErrorinstead.onFinish()— always runs: on success and on failure, even whenonSuccessoronErrorthrows (lukk-nuxt 0.12.0 foronError).
Form State
Beyond processing and hasErrors, the form tracks the outcome of the last submit — handy for buttons and "Saved!" flashes:
<button :disabled="form.processing">
{{ form.processing ? 'Saving…' : 'Save' }}
</button>
<span v-if="form.recentlySuccessful">Saved ✓</span>processing— a submit is in flight. Reset on every exit path (success or failure).wasSuccessful— the last submit succeeded. Reset at the start of the next submit.recentlySuccessful— flips totrueon success and back tofalseafterrecentlySuccessfulMs(default 2000).isDirty— whetherform.datadiffers from the current defaults (see below).
Defaults & Resetting
A form remembers its defaults — initially the data you passed in. reset restores them, and isDirty compares against them:
form.reset() // restore every field to its default
form.reset('email') // restore only some fields
form.resetAndClearErrors() // reset AND clear the errors in one callYou may re-baseline the defaults with defaults — for example after loading an edit form, so isDirty starts clean and reset returns to the loaded values:
const form = useLukkForm({ title: '', body: '' })
const post = await useLukkFetch()<{ title: string, body: string }>('/posts/1') // load current values
form.data.title = post.title
form.data.body = post.body
form.defaults() // baseline := current data → isDirty is false again
form.defaults('title', 'Untitled') // re-baseline a single field
form.defaults({ title: '…', body: '…' })// or severalOn a successful submit, the defaults are re-baselined to the just-submitted data automatically, so isDirty flips back to false after a save. For a create form where you want the fields cleared instead, call reset() in onSuccess:
form.post('/posts', { onSuccess: () => form.reset() }) // clear the form after creatingNOTE
isDirty is a structural (JSON) comparison of data vs. the defaults, so it does not diff File/Blob fields (both serialize to {}) — track those separately if you need to.
Transforming Data
Register a transform to map form.data into the payload sent on every subsequent submit — without mutating the fields the user sees. A classic use is dropping a confirmation field:
const form = useLukkForm({ password: '', password_confirmation: '' })
form.transform((data) => ({ password: data.password })) // send only `password`
await form.post('/password') // YOUR route — not lukk's POST /auth/passwordFile Uploads
Put a File or Blob anywhere in form.data and the submit is automatically sent as multipart/form-data — nested keys are flattened Laravel-style (avatar, tags[0], meta[views]), booleans become '1'/'0', and Dates become ISO strings. The BFF proxy streams the upload.
<script setup lang="ts">
const form = useLukkForm({ title: '', avatar: null as File | null })
function onPick(e: Event) {
form.data.avatar = (e.target as HTMLInputElement).files?.[0] ?? null
}
</script>
<template>
<form @submit.prevent="form.post('/avatar')">
<input v-model="form.data.title">
<input type="file" @change="onPick">
<button :disabled="form.processing">Upload</button>
</form>
</template>Force multipart even without a file with forceFormData: true. For a <input type="file" multiple>, store a File[] (spread the FileList: [...files]) rather than the raw FileList.
NOTE
Files must be sent by a body verb — a File in a get query is stringified and lost. File/Blob fields are passed by reference through reset/isDirty/the success rebase, so large uploads are never byte-copied.
Cancelling a Submit
form.cancel() aborts the most-recent in-flight submit; it then rejects with the AbortError itself — a DOMException named AbortError — rather than the FetchError ofetch wraps it in (lukk-nuxt 0.12.0; before, error.name === 'AbortError' never matched). onError and onFinish still run:
const form = useLukkForm({ q: '' })
async function search() {
form.cancel() // optional: a new submit aborts the previous one anyway
try {
results.value = await form.get('/search')
}
catch (e) {
if ((e as Error).name !== 'AbortError') throw e
}
}If you pass your own signal in the submit options, that wins and cancel() is a no-op for that submit; aborting your signal (without a reason of your own) rejects with the AbortError the same way.
A new submit supersedes the one in flight (lukk-nuxt 0.12.0), as Inertia's does: the earlier submit is aborted and rejects with the AbortError. One that settles anyway — it ran under a signal of your own, which a new submit can't abort — still resolves or rejects its own promise and runs its own hooks, but leaves processing, errors, wasSuccessful / recentlySuccessful and the defaults to the newer submit. Before, a late 422 from the first submit overwrote the second's success, and processing went false while the second was still out.
Notes & Caveats
- Transport-agnostic.
useLukkFormworks identically in BFF and direct mode, and in anuseAsyncData/SSR context, because it submits throughuseLukkFetch— which forwards the session on SSR and injects the bearer as the mode requires. - Field values must be plain. Use strings, numbers, booleans, arrays, plain objects,
Date,File/Blob. Don't pass functions, class instances, or reactive proxies as fields —reset/isDirtyclone and compare them structurally. getflattens only top-level fields into the query string. Nested objects don't round-trip as query parameters.- Chaining.
setError,clearErrors,reset,resetAndClearErrors,defaults, andtransformall return the form, so they compose:form.reset().clearErrors().
NOTE
A custom /register (or bespoke login) route lives on the Laravel side. To hand back a lukk session after your own registration flow, issue tokens with startSession(); to accept a different credential field or add checks at login, use custom login logic.
Next: Using lukk-core.