What's New
Newly added features and improvements to the starter kit are listed here.
2026-09-06 — v13.7.0
Added
-
A dedicated
DATA_ENCRYPTION_KEY, independent ofAPP_KEY, now protects sensitive settings values (mail.password,storage.spaces_secret,storage.aws_secret,turnstile.secret_key,postman.api_key,apidog.access_token) and 2FA secrets/recovery codes. Previously all of this data was encrypted withAPP_KEY, so a routinephp artisan key:generateon a server migration made it silently unrecoverable —SettingServiceswallowed the resultingDecryptExceptionand returnednullinstead of erroring. Three new commands manage the new key:encryption:keygenerates it and preserves the old key inDATA_ENCRYPTION_PREVIOUS_KEYS;encryption:rekeyre-encrypts existing rows onto the new primary key without ever touching a row it cannot decrypt;encryption:healthreports whether the previous-key list is safe to clear, andphp artisan sk:doctorgained a matchingData Encryption Keycheck. Adoption is opt-in — an install that runs none of this keeps working exactly as before, byte-for-byte, and a freshsk:installnow generates the key automatically. See Data Encryption and the server migration runbook. -
SkFormgained areload()method and a.reloadOnDataUrlChange()builder flag.reload()(exposed viadefineExpose) lets a host re-fetchdataUrlon demand — a "Refresh" button, a sibling save event — without remounting the form..reloadOnDataUrlChange(true)opts a form into refetching automatically whenever itsdataUrlprop changes after mount (e.g. a dialog reused for a different record id); the default stays mount-only so a form whose config is rebuilt on every parent render doesn't refetch on every rebuild. See FormBuilder API. -
FB.fileUpload()gained.deferExistingRemoval()and drag-and-drop..deferExistingRemoval(true)changes removing an already-saved file from an immediateDELETE /media/{id}to a deferred one: the item only leaves the field's keep-list, and deletion happens on save viaLvntr\StarterKit\Traits\HasMediaCollections::syncMediaCollection(). The upload field's drop zone now also accepts files dragged onto it, going through the sameaccept/maxFileSize/fileLimitvalidation as the picker button. See File Upload Field API. -
The
@lvntr/componentspackage library is now linted in CI via a newlint:libroot script (eslint --config stubs/eslint.config.js resources/js/components/Lvntr-Starter-Kit), so a lint regression in the shared component library is caught the same way stub-side lint already is. -
TabIconColorandTabBadgeSeverityare now exported from the TabBuilder core barrel (@lvntr/components/TabBuilder/core), matching the already-exportedTabBuilderConfig,TabItemConfig, andTabLayout— a consumer typing against a tab's icon color or badge severity no longer needs to reach into the internal./typesmodule directly. -
TB.tabs()gained five chainable options for panel mounting and URL behavior:.lazy(),.keepAlive(),.history('push' | 'replace'),.urlMode('server' | 'client'), and.syncUrl(boolean)..lazy()mounts only the active panel (PrimeVue's own lazy mode on horizontal layout) while.keepAlive()keeps every panel mounted and hides inactive ones, preserving per-tab state;.history()controls whether a switch replaces the current history entry (default) or pushes a new one;.urlMode('client')rewrites the URL with no server request instead of the default Inertia visit;.syncUrl(false)drops URL sync entirely. See tabs.md. -
SkTabsnow supportsv-modeland emits achangeevent. The optionalmodelValueprop two-way-binds the active tab key in both URL and local mode — a URL deep link wins over a different incomingmodelValueon mount;changefires on every switch after mount (not the initial mount) with{ key, previousKey, tab }. -
SkTabsgained anemptyslot, rendered alone — no sidebar, no tab strip — when no tab is selectable: every tab is filtered out by.permission()/.role()/.visible(), or every visible tab is.disabled()(which used to leave an all-disabled strip with no active panel). -
Vertical
SkTabsis now a proper ARIA tablist. The sidebar nav isrole="tablist"/aria-orientation="vertical", each tab button isrole="tab"witharia-selected/aria-controls/aria-disabledand rovingtabindex, and the panel is wrapped inrole="tabpanel"; Arrow Up/Down, Home/End move focus between enabled tabs and Enter/Space select. -
TabsBuilder.build()now validates duplicate tab keys and returns an immutable snapshot. A duplicate key throws in development builds and logs aconsole.errorin production instead of staying silent;TabItemBuilder.build()also now rejects a whitespace-only key, not just a missing one; everybuild()call returns a fresh copy of the config and its tabs, so a later.addTabs()on the same builder or mutating the returned config can no longer affect an already-built config. -
TabPanelMode,TabHistoryMode,TabUrlMode,TabChangePayload, andSkTabsExposedare now exported from the TabBuilder core barrel (@lvntr/components/TabBuilder/core), alongside the existingTabBuilderConfig,TabItemConfig,TabLayout,TabIconColor, andTabBadgeSeverityexports. -
useUrlTab()now accepts arefor a getter for itstabsargument, in addition to a plain array, and a new{ history: 'push' | 'replace' }third argument. The value is read throughtoValue()on every access;history: 'push'gives each switch its own history entry instead of the default'replace'. -
Static analysis (Larastan/PHPStan, level 5) now runs against
src/in CI, alongside Pint.phpstan.neonscansstubs/appso the package'sApp\*references (the consumer-owned classes the kit assumes exist, e.g.App\Models\User) resolve without a full consumer app;phpstan-baseline.neonsnapshots the pre-existing findings so the gate holds only new code to the bar. Run locally withcomposer analyse. The baseline has since been narrowed from 259 to 238 findings as fixable issues were triaged out of it. -
make:sk-domaingained a--with-permissionsopt-in flag (also selectable via--with=permissions). It registers the new domain's resource — all default abilities — inconfig/permission-resources.phpalong with an English display name; the Turkish label and role assignment are deliberately left for you to fill in, since neither can be inferred safely from the domain name alone. See Artisan Commands. -
A Playwright admin smoke suite now runs in CI.
tests/e2e/specs/admin-smoke.spec.tsdrives a real fixture app — scaffolded byscripts/bootstrap-fixture-app.shand seeded viascripts/e2e/fixtures/E2EAdminSeeder.php— through login, domain CRUD and settings screens; a newe2eGitHub Actions job builds the fixture and runs the suite headless on every push. Run it locally withnpm run test:e2e. See testing-e2e.md. -
sk:installgained an opt-in--modules=telescope,pulse,horizon,sentryflag for optional observability recipes. Left off, a TTY install prompts for which of Laravel Telescope, Laravel Pulse, Laravel Horizon, and Sentry to add; a non-interactive run (or an empty--modules=) skips the step entirely, so nothing changes for an install that ignores the flag. Each selected recipe runs its owncomposer requireplus post-install commands as a best-effort step — one recipe's failure doesn't abort the install or block the others, and any recipe that didn't fully install is listed at the end with a note to run it by hand. -
The kit's API documentation now runs on
lvntr/api-dock, a new dependency, replacing Scramble's own UI as the reading surface.sk:installpublishesconfig/api-dock.phpon every install, and api-dock mounts its panel at/api-dock(the raw document at/api-dock/spec), documenting the same OpenAPI document the kit already generates from route attributes. Unlike the Scramble surface it replaces, the panel is gated behind the seededapi-docs.readpermission — previously that docs route had no permission wired to it at all. That gate does not depend on the install step running: api-dock's packaged default serves the panel,/api-dock/specand the try-it proxy behind['web']alone, so whileapi-dock.middlewareis still that untouched default the kit stacks['web', 'auth', CheckApiDocsAccess::class]on it — and disables api-dock entirely when the middleware class is not in the app yet, rather than serving it anonymously. Four new commands come with it:api-dock:exportsnapshots the document,api-dock:diffcompares it against a stored baseline,api-dock:syncpushes it to Postman/Apidog, andapi-dock:agent-guidegenerates AI-oriented artifacts — anllms.txtplus an MCP tool export (opt-in, and only for operations carrying anAiToolattribute). See api.md. -
sk:updateandsk:doctornow detect a kit-required Composer package the app never installed.sk:updateprints the missing packages and, on a real TTY without--dry-run/--no-interaction, asks before runningcomposer update lvntr/laravel-starter-kit -W;--no-interactionand--dry-runonly print the command.sk:doctorreports the same condition. -
The kit's own auth and definitions API operations now carry
lvntr/api-dock's AI metadata attributes (AiHint,AiPitfall,AiExample,AiTool,ApiFeature).logindocuments that all three of its outcomes return 200 and that only the presence oftokensignals a completed login;registerdocuments its deliberate 403 (not 404) when registration is disabled. Onlymeand the definitions lookup carryAiTool, since they're the only side-effect-free reads. Metadata only — no behaviour or route changed. See api-ai-metadata.md.
Security
-
A FileManager file on a local or public disk no longer resolves to a permanent, unauthenticated public URL. It previously fell back to
Media::getUrl()whenever the disk doesn't support temporary/signed URLs — a link that bypasses authorization entirely and keeps working after a permission revoke or a move to trash. It now falls back to a new authorizedfiles.previewroute, gated the same wayfiles.downloadalready is; S3 and any disk with temporary-URL support is unaffected. Only a conservative allowlist of passively-rendered types (images, audio, video, PDF, plain text, CSV) is served inline through it — everything else is sent as an attachment. See UPGRADE.md. -
Logging out — and revoking a user's access — no longer leaves a live OAuth refresh token behind. A refresh token outlives its access token by design, and
RevokeUserAccessActiononly revoked access tokens that were still marked live, so an account already logged out or revoked once could still hold a refresh token bound to that revoked access token and mint a new one on demand. BothRevokeUserAccessActionand the stubLogoutUserActionnow revoke the refresh token bound to a token first and the access token second, through a newRevokesOAuthCredentialstrait. See UPGRADE.md if you customisedLogoutUserAction. -
Copying a file in the FileManager no longer bypasses the storage quota. Only the upload path checked
storage.quotaagainst usage;CopyFileActioncreated a full new blob per copy with no ceiling. It now runs the same quota check before the physical copy and rejects with the samequota_exceededmessage the upload path uses. -
Rotating
DATA_ENCRYPTION_KEYno longer silently drops — or gains — a file-specific ACL on.env. Asetfacl/chmod +agrant on.envis invisible tofileperms(), so it did not survive the atomic rename even though owner/group/mode did.encryption:keynow carries the ACL over and verifies it by reading it back, refusing the rotation when it cannot. The mirror case is also closed: a directory-level inheritance rule can put an ACL on the temp file that.envnever had, before the key is even written into it — that inherited entry is now normalised away first. Either mismatch is refused; a new--allow-acl-lossflag downgrades the refusal to a warning for an operator who will reconcile it by hand. -
stubs/package.json's@tiptap/*packages moved from^3.22.4to^3.31.3to pick up upstream fixes;stubs/package-lock.jsonregenerated. No used API changed across that range. -
File uploads are now checked against an extension allowlist derived from their accepted MIME types, and
media-library's active-content extensions are now blocked unconditionally. The FileManager's public disk serves a stored file back under its own client name, and the existingmimetypes:rule only looks at the sniffed content — so a file whose bytes matched an accepted type but whose client name ended in.htmlwas previously stored and served as active content.UploadFileRequestnow derives anextensions:rule from a newmimeExtensionMap(), reusing the existingsk-file-manager.errors.upload_invalid_typemessage, so an upload named e.g.payload.htmlis rejected with a 422 even though its bytes are a valid accepted type; the shippedUploadAvatarRequeststub carries the matchingextensions:jpg,jpeg,png,webprule too — existing installs get it viaphp artisan sk:update, or by adding the rule by hand.media-library.disallowed_extensionsis now hardened unconditionally, with no opt-out, to also blockhtml,htm,xhtml,xht,svg,svgz,xml,xsl,xslt,js,mjs,hta: Spatie checks every dot segment of the client name, not only the last, so a double extension such asname.html.pdfis refused at this layer even though.pdfalone would pass the request-level rule. Spatie's ownFileNameNotAllowednow maps to a 422 ("The uploaded file name is not allowed.") instead of surfacing as an opaque 500. The same per-segment check now also runs insideUploadFileRequestitself (a field-level 422 instead of the exception path, and independent of the installed media-library version), and renaming a file can no longer change its extension:RenameFileRequestrequires the new name to keep the current extension (case-insensitively) and refuses any blocked segment, because the media library physically renames the stored file onfile_namechange and a rename to.htmlwould otherwise turn a validated image into active content. MIME types outsidemimeExtensionMap()(admin-added PPTX, RAR, Markdown, …) now resolve their extensions from Symfony's MIME database instead of the raw subtype, andtext/plainaccepts.txt,.log,.md,.csv,.json,.ini,.ymland similar text names. Spatie'sFileIsTooBig(its ownmedia-library.max_file_size, 10 MB by default, independent of the requestmax:rule) now maps to a 422 too, and the shippedSettingsServiceProviderstub keeps that ceiling in step with the FileManagermax_size_mbsetting. The kit now requiresspatie/laravel-medialibrary^11.23(per-segment blocking shipped in 11.23.0), socomposer updatepulls a build that enforces the list. See docs/UPGRADE.md. -
The settings cache now stores ciphertext for encrypted values instead of decrypted plaintext.
SettingService::allGrouped()cached the fully-decrypted row set for an hour under thesettingskey, so any cache backend (Redis, a file store, a store shared with another app) heldmail.password, storage secrets and similar values in plaintext for that hour. Raw rows are now cached under a newsettings:v2key and decrypted after every cache read; the legacysettingskey is forgotten the first time the new snapshot is built, andphp artisan encryption:rekeyclearssettings:v2after re-encrypting rows onto the new key. An unreadable encrypted row (a rotated or wrongDATA_ENCRYPTION_KEY, corrupted ciphertext) is now retried — and logged — on every read instead of being cached asnullfor the full hour, so restoring the correct key takes effect on the next request rather than after the cache expires. -
The API login endpoint now has its own per-account rate limit, on top of the existing per-IP one.
POST /api/v1/auth/loginmoves to a newapi-loginlimiter: 5/min per IP (unchanged) plus a new 3/min per email address, so an attacker spreading guesses for one account across many IPs is now stopped too. This limiter can never be relaxed by theauth.login_throttlesetting, which only affects the separate web login route. It is registered by the package itself, not by the publishedFortifyServiceProvider, so it can never go missing whensk:updaterefreshes one published file but preserves another. If you customised the publishedroutes/api/public-api.php, see UPGRADE.md to port the route change by hand. Because the three/api/v1/auth/*endpoints used to share one unnamed per-IP bucket andloginnow has its own, the group's combined per-IP allowance goes from 5 to 10 requests a minute; the login route itself is unchanged at 5/min per IP. -
A new opt-in flag can serve your admin panel's script tag with a per-request CSP nonce instead of
'unsafe-inline', closing the door on an injected inline<script>executing. SetSTARTER_KIT_CSP_NONCE=trueto turn it on. It defaults tofalsebecause it requires thenonce="{{ Vite::cspNonce() }}"attribute on your publishedresources/views/app.blade.php— without it, turning the flag on breaks the panel's theme script silently. A brand-newsk:installalready has both pieces in place. See UPGRADE.md before enabling this on an existing install.
Changed
-
A titled
SkDatatabletoolbar now splits into two rows: the heading and its buttons on top, search and filters underneath.DB.table().title()put the heading, the search box, the inline filter pills, the filter/columns buttons, the create button, the#toolbar/#toolbar-startslots and — under aura — the hosted page header on a single line, which ran out of room as soon as a table carried more than a filter or two. The head row now matches.sk-card__headpadding exactly (title/subtitle on the left, create button and toolbar slots on the right), so a titled table reads like an ordinary card heading, and search, inline filter pills and the filter/columns group wrap onto their own line beneath it. A toolbar with no title is unchanged — one row, actions last. -
Under aura, a card-surfaced
SkDatatablenow draws the page heading inside its own toolbar instead of a strip above it. The layout's page header — heading, subtitle, back button and#page-actions— was hosted by the wrappingSkCard, which drew it as a head row of its own directly above the table toolbar, so Users, Roles, Activity Logs and Log Files each showed a heading block and then a separate toolbar underneath. The table now claims that header itself and renders it as the toolbar's head row, above the same divider a titled toolbar already puts between its heading and its search/filter line, so the heading, its page actions and the table's own controls read as one header. A table that carriesDB.table().title()is unchanged — its own title stands and the hosted header still contributes only the back button and the page actions — and themaintheme, which draws the page header in place and never hosted it in a card, is untouched. The Activity Logs and Log Files screens dropped their now-redundant.title()so their heading comes from the page like every other screen's. -
Horizontal
SkTabsnow draws its tab strip inside a card of its own. The strip sat bare on the page background above the panel; it now lives in anSkCard, and under aura that card is also the one hosting the layout's page header — so a tabbed screen reads as one block: heading, subtitle, divider, tab row, then the panel. The tabs themselves dropped PrimeVue's default tab chrome for a flat icon + label, muted until active, with the primary color and a 2px bottom bar as the only active indicator. Panels keep their own card exactly as before and the vertical layout is untouched. One class rename to be aware of if you styled it yourself: the horizontal tab icon is now.sk-tabs__icon, not.sk-vtab__icon(the vertical nav still uses the latter). -
The aura theme no longer moves the page title into the topbar;
AdminLayouthands its one page header to the content card's own head instead. Aura used to hand the title, subtitle and back button toAdminHeader, which left the content's first card headerless and forced every page that wanted a title or an action into a theme branch of its own — theUsers/Rolesindex pages set the datatable's.title()and moved their create button into the table toolbar only under aura, andApiRoutes/Indexhand-rolled a whole aura-only header card. The.title()call was unconditional, so themaintheme printed the page heading twice. All of it is gone.AdminLayoutnow renders exactly oneAdminPageHeader— title, subtitle, back button,#page-actions— and hands it to the active theme: undermainit renders in place as the strip above the content, and under aura the firstSkCardthat claims the header draws it in a head row of its own (the datatable card, the form card, an active tab's card).SkCardowns that hand-off: eligible cards register as candidates in mount order and the first one still mounted draws the header, so a switched tab or an Inertia visit — where the incoming card mounts before the outgoing one unmounts — passes it straight on instead of dropping it back outside the card. ASkDatatablewhose toolbar carries a title (DB.table().title()) hosts the header in that toolbar instead and opts its wrapping card out, so a table heading never sits under a page heading. A card that already has a title of its own keeps it and folds the header into that same head row — back button and page actions only, no page heading above the card heading. A card with no title (the datatable card, a plain form card) gets the full header in a head row of its own. Transparent wrappers and dialog bodies never register, and chrome cards opt out withhost-page-header="false"— the vertical-SkTabsnavigation card,AvatarUploadand a FormBuilder section, so the header lands on the real content card and never in a page's sidebar. Kit screens that hand-rolled their own card surface (the settings Appearance tab, the API-routes page, the log viewer) now useSkCardtoo, so aura shows the header in the same place on every screen. A page component never branches on the active theme. The Files screen renders noSkCardat all — the file manager is its own surface — soFileManager.vueclaims the header itself and draws it at the top of its sidebar rail, above the Upload new file button, instead of leaving aura to fall back to the bare strip above the manager. Breaking for consumer pages: theheader-in-cardprop onAdminLayoutand theusePageHeader()composable (resources/js/composables/usePageHeader.ts) were removed — drop the prop, and delete any#title-endback button that readpageHeader.active; the layout draws that button now.AdminHeader'spage-title,page-subtitleandshow-backprops and itsbackemit were removed with them. Candidacy is reactive on both axes: switching the theme on the Appearance screen re-decides it while the cards stay mounted (it used to freeze at setup, drawing the header twice on aura → main), and a card inside an inactive-but-mounted tab panel (horizontalSkTabs, or anypanels: 'all') stands down so the header follows the visible panel instead of disappearing with the hidden one.AdminHeader.vuestill accepts the old optionalpageTitle/pageSubtitle/showBackprops and still emitsback:sk:updaterefreshes it andAdminLayout.vueindependently, so an app that customised the layout keeps the old one, which still binds them. -
Vertical
SkTabstab buttons now carryrole="tab"instead of no explicit role, and the panel content is wrapped in a newrole="tabpanel"<div>inside the card body. A test selecting a tab button withgetByRole('button')must switch togetByRole('tab'); custom CSS relying on a direct-child selector under the card body may need a look. -
TabsBuilder.build()now throws on a duplicate tab key in development builds, and logs the same message viaconsole.errorin production instead of staying silent. Duplicate keys used to silently break slot resolution and URL selection — the second tab rendered the first one's content and could never be reached via?tab=. -
SkTabsno longer imports the publisheduseUrlTabcopy from@/composables; it now owns an equivalent active-tab state internally. This removes a version-skew risk (sk:publish --tag=composablescould leave an app on an olderuseUrlTabthan the shipped component expects), but a project that hand-edited its publisheduseUrlTab.tsspecifically to changeSkTabs' behavior will no longer see that edit take effect —useUrlTab()itself is unaffected for app code that calls it directly. -
Cross-page "select all filtered" bulk selection now fails closed on an unsupported filter instead of silently dropping it.
BulkFilterSnapshot::normalize()rejects an activefilter[...]key it can't apply with a 422 (sk-bulk.unknown_filters) rather than dropping it from the snapshot, which previously resolved a set wider than what the table showed and let a bulk action reach rows the dropped filter was hiding. Affects the shippedUserBulkSelectionQueryandRoleBulkSelectionQuery. Only anullvalue or an empty array counts as inactive — the two shapes Spatie'sAllowedFilterskips; an empty or whitespace-only string is passed through verbatim and applied with the table's own predicate (an exact filter yields the same empty set the table showed,search/date bounds ignore it), so a blank value can never widen the bulk set either. -
DatatableQueryBuilder::columns()payload shaping is fail-closed. A?columns=request parameter with no key matching a declared column previously fell back to returning the full row; it now reduces every row to thealwaysInclude()keys only, matching the "declared column keys must match the frontend" contract. -
TB.tabs().queryParam()now rejects an empty or whitespace-only name. It throws in development builds andconsole.errors in production, keeping the name already set (thetabdefault or an earlier call) — an empty name used to be stored as-is and produced a?=keyURL parameter that nothing could read back, so the tabs silently stopped syncing with the URL. -
The installer commands now exit non-zero when a mandatory step fails.
sk:install,sk:update,sk:upgrade(and the publishedsite:installstub) invokedmigrate,db:seed,vendor:publish,sk:seed-permissions,passport:keys,key:generate,composer dump-autoload,npm installandnpm run buildwithout ever reading the result: amigratethat died on a bad connection still printedDONE, the resume checkpoint recorded the step as finished, the stub-hash registry was written, and the command exited0— a CI job went green over a half-installed application. Every sub-command result is now checked; a failed mandatory step (publish, migrations, seeders, permission seeding, Passport keys, encryption keys) aborts the run, leaves the checkpoint pending sosk:install --resumepicks up where it stopped, skips the registry write, and ends with one line naming the failed step and the resume command. Frontend steps stay non-fatal on purpose —npm install, the Wayfinder generation andnpm run build(pluscomposer dump-autoloadand cache clears) only warn, print the command to run by hand, and are listed again in the closing summary, so a machine without Node or composer still installs exactly as it does today. A CI pipeline that currently passes with a silently failing migration will now go red — see docs/UPGRADE.md. Thesite:installchange lives in a stub, so it reaches new installs andsk:update-refreshed apps only; an existing consumer copy is untouched. -
Breaking: the kit's API documentation moved from Scramble's default
/docs/apito the new api-dock panel at/api-dock. Scramble's own routes are now explicitly disabled, so/docs/apiand/docs/api.json404 on any app that updates. Anything bookmarking or linking the old URL must move to/api-dock, and a consumer who published their ownconfig/scramble.phpshould check it no longer registers a competing docs route. See UPGRADE.md. The switch-off is conditional on the replacement actually being there:Scramble::ignoreDefaultRoutes()now also requiresLvntR\ApiDock\ApiDockServiceProviderto exist, so an install that carries this version's source without having let Composer resolve its new requirement — a path/VCS repository trackingdev-main, avendor/restored from an older lock, an install that pinned api-dock away — keeps Scramble's routes instead of ending up with no documentation surface at all (api-dock is not registered in that window either, and the API Routes screen hides its panel button on the same missing route). The normal upgrade path is unaffected:composer update lvntr/laravel-starter-kitinstalls api-dock along with the kit. -
@tiptap/extension-task-itemand@tiptap/extension-task-listwere removed fromstubs/package.json— neither is imported anywhere in the kit's own code. If your own code imports either directly, add them back to your app's ownpackage.json. -
stubs/app/Models/User.php's@propertydocblock was corrected to match the model's actual columns and relations. No runtime behaviour changed. -
phpstan-baseline.neonwas triaged and narrowed from 259 to 238 findings, fixing a handful of genuinely-fixable issues (stale docblocks, unreachable branches) rather than carrying them. -
CI's real-database jobs (MySQL, MariaDB) now block merges instead of only reporting, now that they've run green for a release.
-
release.shnow requires the GitHub CLI (gh) and a green remote CI run on the exact commit being tagged, checked before the local quality gate. It also now runscomposer analyseand a full frontend gate (build/typecheck/lint/test). Release flow changed: push toorigin mainand wait for CI to go green before running./release.sh. This is repo-tooling only — it does not affectcomposer updateorsk:update. -
sk:doctor's 22 checks now resolve their name, message and hint strings throughsk-doctor.*lang keys instead of hardcoding them in PHP. Every class insrc/Console/Doctor/Checks/calls__('sk-doctor.<check>.<case>', [...])for its user-facing text, so both the admin System Health screen andphp artisan sk:doctornow follow the app's active locale instead of always printing English. English wording is unchanged byte-for-byte, and a consumer app can override any single string by publishingapp/lang/<locale>/sk-doctor.phpand redefining just that key — the namespace-less lookup already used bysk-bulk.*and friends means the app copy wins over the package's own.sk:doctor --only=<selector>is unaffected by the translation: selectors are now derived from each check's CLASS name (DatabaseConnectionCheck→database-connection) rather than its displayed name, so they stay identical in every locale, and the three documented selectors that never matched their class name (filemanager-disk,permission-matrix,unresolved-routes) keep working as aliases. -
The frontend build/lint/test tooling in
stubs/package.jsonmoved onto its current releases:@types/node^26.5.0,concurrently^10.0.5,eslint-plugin-vue^10.11.0,typescript-eslint^8.70.0andunplugin-vue-components^32.1.0. None of these reaches a shipped bundle — no runtime dependency changed and no component source was touched — butunplugin-vue-componentscrossing a major does affect the consumer's own Vite build, so the whole gate was re-run against it:vue-tsc --noEmit, ESLint over bothstubsand the@lvntr/componentslibrary, the 290-test Vitest suite, and both Vite builds (client and SSR).
Fixed
-
An admin page taller than the viewport scrolls again instead of being cut off at the bottom.
.admin-contentis the scrollport and lays its children out as a flex column, so every page root is a shrinkable flex item — and a child that clips its own overflow (.sk-cardisoverflow: hidden) resolves its automatic minimum size to0rather than to its content height. A long form card was therefore squeezed into the leftover viewport height and clipped its own fields, while the scrollport itself had nothing left to scroll: the page looked frozen with the last section and the footer buttons unreachable. The scrollport's children now carrymin-height: min-content, so a page keeps its natural height and scrolls; a root that deliberately fills the viewport instead (the file manager'smin-h-0 flex-1shell) is unaffected, and datatable screens, which cap their own body height against the same scrollport, render byte-identically. -
sk:doctorno longer warns about an unverifiable queue worker when Horizon is running. TheQueue Workercheck treated every async driver alike, so aredisqueue always reported "worker liveness cannot be verified automatically" — including on the many installs that run Laravel Horizon, which publishes exactly that liveness itself. On aredisqueue the check now reads Horizon's master supervisor record when Horizon is installed: a running master reports OK with the supervisor count, an entirely paused one warns and points athorizon:continue, no master at all warns that Horizon is installed but not started, and a status that cannot be read (Redis down, for instance) warns with the underlying error. Horizon stays a non-dependency — its contract is resolved by FQCN string behind aninterface_exists()guard — so an install without it, or one onsqs/beanstalkd, keeps the previous generic warning unchanged. -
sk:installnow names the cause when the existinguserstable cannot host the kit's schema, instead of dying on a foreign key. The kit keysuserson a uuid; a stock Laravel app that ranphp artisan migrateBEFORE installing the kit hasuserswith abigIncrementsid — and has stock Laravel's users migration recorded in the ledger under the same filename the kit publishes (0001_01_01_000000_create_users_table.php), so the kit's uuid version of that file never runs. The mismatch surfaced several migrations later as a bareSQLSTATE[HY000] ... 3780namingfile_folders.created_byandusers.id, with nothing in it an operator could act on. The migration step now probes the existingusers.idbefore offering a strategy: it prints the cause and the remedy, labels the additive optionWILL FAIL, and stops the step with that reason if it is chosen anyway (or the session cannot prompt). Unlike the row probe that guardsmigrate:fresh, this one fails open — an unreadable schema is never treated as a conflict, so it cannot block an install on a question it could not answer. The fix is a full reset:migrate:fresh, thefreshoption when offered, or an empty database, thensk:install --resume. -
sk:installis no longer documented as a recovery path for an existing project.docs/install.mdanddocs/update.mddescribed re-running it as an idempotent whole-project repair. It is not: the hash registry only protects a file you deleted rather than one you edited, and a consumer-edited published file is skipped and reported rather than refreshed unless--forceis passed, in which case it is overwritten outright — neither mode gives the selective, edit-preserving refreshsk:updateor a scopedsk:publish --tag=<area>gives. Both guides now carry the warning, andUPGRADE.mdrecords the boundary. -
sk:installrefuses to run on an app it did not install. The command trusted its own hash registry (storage/starter-kit/hashes.json, git-ignored) as the only signal that a project was already installed; losing that registry made a live application look brand new. A fail-closed detection pass now runs ahead of the banner — kit schema tables and install-only paths are checked, and if any are present without a matching registry, the command stops before writing anything.sk:updateand the newphp artisan sk:install --adopt(rebuilds the registry only,--dry-runpreviews it) are named as the way out;--forcestill proceeds but is no longer treated as a first install. -
An existing
.envis never overwritten bysk:install, on a first install or a re-run. A first install used to copy.env.examplestraight over an existing.env, destroyingDB_PASSWORD,APP_KEYand anything else already configured. The installer now merges: missing.env.examplekeys are appended and first-install-only keys are seeded only where absent, and no existing value is ever rewritten..envis created from.env.exampleonly when it does not already exist. -
A consumer-modified published file is now skipped by
sk:install's re-publish path too, not onlysk:update's. Both commands share the same three-way stub/target/registry-hash comparison; a file that no longer matches the last-recorded hash is treated as a consumer edit, skipped, and reported instead of silently overwritten.--forceremains the opt-out. -
sk:installno longer overwrites an untracked file on a re-install. A file the hash registry had no record of at all — because a newer package version started shipping into a path it had never shipped into on this app before — used to be overwritten regardless of--force. It is now treated the same as a consumer edit: preserved and reported, unless--forceis passed. The protection only applies once a registry exists; a genuine first install still publishes every path, tracked or not. -
sk:install's destructivemigrate:freshoption now requires a typed confirmation, not aselect()answer. The install-time menu offered "drop all tables and run fresh migrations" as an ordinary yes/no choice, one wrong keystroke away from an irreversible reset. Choosing it now prompts for the database name (or the wordfresh) typed at atext()prompt; anything else, including an empty answer, falls back to the additivemigratepath with nothing dropped. The option is also withheld outright whenAPP_ENVlooks production-like,APP_DEBUGis off, the session cannot prompt, or any existing table already holds rows. -
An account disabled while its session is still open is now logged out on the next request. The login path already refused a non-active account, but could not reach an already-open session. The new
EnsureUserIsActivemiddleware checksstatuson everyweb/apirequest and terminates the session when it matches the operator's deny-list (starter-kit.security.active_status_denied, default['inactive', 'banned']); it is deliberately fail-open on every ambiguous case (no status attribute, non-string value, an unlisted status) and can be disabled outright viastarter-kit.security.enforce_active_status = false. -
A setting that cannot be decrypted is no longer silently indistinguishable from an unset one.
SettingServicecaught everyExceptionwhile decrypting and returnednull, whichallGrouped()then cached for an hour — so a wrong key, a corrupted payload or a misconfigured cipher quietly fell back to the env/default value on mail, storage and Turnstile. OnlyDecryptExceptionis handled now (stillnull, but logged without the ciphertext); anything else propagates. -
encryption:healthnow fails closed when its config is cached and stale. Underconfig:cache, the command could not tell "cache predates an.envedit" from "the value was never sourced from env at all" — so a stale cached chain could reportsafe-to-clearon a key set the app was about to stop using. A cached configuration whose resolved key chain no longer matches.env/the process environment now downgrades the verdict toincomplete(exit 1) instead, with aRun php artisan config:clearinstruction, closing a path that could have turned a rotation into permanent data loss. See encryption.md. -
sk:install's existing-app detection markers were dead in production.EXISTING_APP_DIRECTORY_MARKERScarriedresources/js/Pages/Admin(uppercasePages), which the stub tree never ships — only case-insensitive local filesystems (macOS) masked the mismatch.KIT_SCHEMA_TABLESchecked for afile_manager_folderstable this kit has never created, instead of the actualfile_folders. Both are corrected, restoring the fail-closed detection pass those markers exist for. -
An unreachable database at install time no longer reports a successful install.
sk:installused to skip the database block (migrations, seeders, permission seeding) on a connection failure with only an on-screen warning, still write the stub-hash registry, clear the resume checkpoint, and exit0. The run now ends incomplete: the registry is withheld, the checkpoint is preserved so--resumecontinues exactly where it stopped, and the command exits non-zero. See UPGRADE.md. -
sk:installno longer deletespackage-lock.jsonon a re-install or a--resume. The lockfile is the application's pinned dependency graph, andinstallFrontend()removed it unconditionally beforenpm install— so a re-run re-resolved every package onto versions the app had never been tested against, even while the run's own summary reported the file as kept. It is now removed on a first install only (where a lock left over from an unrelatedpackage.jsonis only in the way), and the decision moved inside theInstalling npm dependenciesstep: a--resumerun that skips that checkpointed step no longer deletes the lockfile the first run had just written with nothing left to regenerate it. -
A
--resumerun no longer wipes thenode_modulesit just installed. Clearing the stale dependency tree sat in front of theInstalling npm dependenciesstep and ran only when a tree already existed — so on a first run there was nothing to clear and nothing to checkpoint, while thenpm installthat created the tree WAS checkpointed. An interrupted install followed bysk:install --resumetherefore deleted the freshly installednode_modulesand then skipped the install that would have refilled it, leavingnpm run buildto fail on missing dependencies. The clear now happens inside the same step, next to the lockfile decision: skip the step, skip both. -
encryption:key's "Next steps" output now matches the documented rotation order. The numbered list opened withencryption:rekey, while a cached configuration still resolves the key that was just retired — so an operator following the output re-encrypted every row onto the wrong key, or rewrote nothing at all. When config is cached,php artisan config:clearis now listed as its own first step, ahead of the rekey, exactly as Data Encryption prescribes. -
encryption:key's.envreads now match what the running app actually resolves.DATA_ENCRYPTION_KEY,APP_KEYandDATA_ENCRYPTION_PREVIOUS_KEYSwere read out of.envwith a hand-rolled regex that returned a${VAR}-interpolated assignment (e.g.${APP_KEY}) verbatim instead of resolving it, and disagreed with the real dotenv parser on inline comments and some quoting. Reads now go through the same parser the app boots with (Dotenv::parse()), so an interpolated reference resolves the same way, and the resolved material — never the literal reference — is what gets prepended toDATA_ENCRYPTION_PREVIOUS_KEYS. A.envthe parser cannot read now aborts the rotation before a key is generated or anything is written, instead of misreading it silently; the parser's own error is withheld from the report because it can quote the malformed line back, which may itself be key material. The rotation also stops when the process environment overrides one of those keys — directly, or through a variable that an interpolated value references — because the file would then state one value while the running app resolves another; rewriting.envcould not close that gap, since the process value keeps winning, so the only safe move is to stop before a key is generated and let the operator resolve the divergence. -
A
mediatable migration rollback no longer leaves the schema and the ledger disagreeing.create_media_tablehad nodown(), and Laravel's migrator guards that call withmethod_exists— sophp artisan migrate:rollbacksilently skipped the table while still deleting the migration's ledger row. The table survived, its record did not, and the nextmigratefailed on a table that already existed. It now declares adown()that refuses rather than destroys: an empty table is dropped, and a rollback attempted while rows remain stops with an error. Dropping a populatedmediawould remove the rows, not the files — Spatie deletes the underlying blobs only through the model's deleting event, which a schema rollback bypasses, so the disk would be left holding orphaned files with nothing left to index them. Delete the media through the application first if you mean to roll it back. The two later migrations in the same chain (add_folder_id_to_media_table,add_soft_deletes_to_media_table) carry the identical refusal, because a batch rolls back newest-first: without it they would have droppedfolder_idanddeleted_atoff a populated table before the create migration's guard was ever reached. See UPGRADE.md. -
definitions.langis narrowed so the table's composite unique index stops sitting under InnoDB's key-length limit.unique(['key', 'value', 'lang'])over three default 255-character columns sat at 3060 of the 3072-byte limit — a single character of headroom on any one column away from breaking outright.langnarrows to 35 — the widest locale value the kit already accepts anywhere (content_languages.code), so nothing storable through the kit's own screens is affected — whilekeyandvaluekeep their published 255, becauselangalone leaves ~892 bytes of headroom and narrowing them would only block data the current schema accepts; a new migration measures every existing row, soft-deleted ones included, before touching the schema and refuses — leaving the schema unchanged — if a single row would be truncated. Both directions end by asserting the unique index exists, so a table that reaches the migration with the index already missing gets it rebuilt instead of being recorded as migrated without its guarantee. See UPGRADE.md for what to clean up if it refuses. -
sk:installno longer deletes conflicting default Laravel files on a re-install or an update. Removingpackage-lock.json,vite.config.*andresources/js/app.jsunconditionally destroyed project state the installer has no mandate over on any run past the first. Deletion is now first-install-only; a later run reports the conflicting files it found and left alone instead of deleting them. -
The MySQL/MariaDB CI slice now runs the real migration chain instead of the install test suite.
tests/Feature/Installexercised installer logic, not migrations, so a migration that broke on MySQL or MariaDB's stricter DDL could pass CI unnoticed; the MariaDB job also ran through themysqldriver, souuidcolumns never took MariaDB's native path. Both jobs now runtests/Feature/Migrationagainst their own driver (mysql/mariadb). -
The settings cache is cleared after the outer transaction commits, not during it.
setValue()/setGroup()calledCache::forget('settings')inline, so a write wrapped in an outer transaction (UpdateAuthSettingsAction) dropped the snapshot while the rows were still uncommitted — a concurrent reader could miss, re-read the pre-write rows and cache them for another hour. The clear now runs throughDB::afterCommit(), which still fires immediately when no transaction is open. -
Logo, favicon and avatar uploads store the new file before dropping the old one. All three deleted the existing asset first, so a failed
store()left the setting pointing at a file that no longer existed. A failed upload now leaves the current image in place and returns an error instead. -
A media object is removed from disk only once its row's deletion has committed. Spatie's
MediaObserver::deleted()removes the file inside the transaction that deleted the row, so a rollback restored a row pointing at a file that was already gone. The removal now goes throughDB::afterCommit(): it is discarded on a rollback, and the worst remaining outcome is an orphaned file, which is recoverable. A non-transactional delete keeps today's timing and still surfaces its failure. -
Restoring a folder from trash no longer creates a duplicate name.
CreateFolderActionrejects a duplicate, but the trash was a way around it at the root level, where MySQL and SQLite treat two NULLparent_idvalues as distinct and the unique index does not fire. The restore now refuses with the same domain error. -
The FileManager quota calculation works on a bare Spatie
Mediamodel again.computeStorageUsed()calledwithTrashed()unconditionally; without the SoftDeletes trait that macro does not exist, so every upload validation threwBadMethodCallException. It goes through the capability-aware helper the rest of the trait already used. -
file-manager:purge-trashno longer loads the whole trash into memory, and reports failures. The command read every matching row withget()before deleting, takes a cache lock so two schedulers cannot purge the same rows at once, walks the rows withchunkById(--chunk=, default 500), keeps going when one item fails, and returns a non-zero exit code when anything was left behind. The published schedule entry gainedwithoutOverlapping(). -
Console output points at documentation URLs that exist in an installed app.
/docs/**isexport-ignored, so aprefer-distinstall has no docs directory — yetsk:install,sk:update,sk:upgradeandsk:doctorprinted localdocs/…paths mid-migration and during key rotation. They now print a URL pinned to the installed version. -
encryption:healthandencryption:rekeysay which surfaces they can actually vouch for. Both reported on the kit's own key chain while a consumer-installed encrypter on the Fortify or model-cast path was invisible to them, so a rekey could report success over rows it never re-encrypted. Each surface is now reported with the encrypter that serves it, an encrypter the kit did not build is named as unvouched (verdict: not-covered, exit 1), andencryption:rekeyrefuses before reading a row instead of printing a complete rekey. The stale-published-config gap — aconfig/starter-kit.phppredating the encryption block, whereDATA_ENCRYPTION_KEYis inert while health reported "safe to clear" — is reported as its own diagnosis. -
An encryption key is never written into a file whose permissions could not be restricted.
encryption:keychmod'ed its temp file to0600without checking the result; on a filesystem that ignores permissions the very next line wrote key material into a world-readable file. The mode is verified while the file is still empty, and the command aborts otherwise. -
encryption:keyandencryption:rekeycannot run at the same time. Both read the key chain, decide a new one and write it back, so two concurrent runs could drop a key that was still needed to read existing rows. They now share one cache lock; a--dry-runrekey is unaffected. -
DELETEon a FileManager file validates its context like every other route. The endpoint built the context DTO straight from the request, so a malformed one surfaced as a 500 instead of the documented 422 envelope. -
The timezone select in the Users create/edit dialog now lists every timezone instead of only the site default.
UserFormreceives the identifier list as a prop, and theAdmin/Users/Indexpage — where both dialogs are opened — never passed it, so the component fell back to its empty default and the select offered a single "site default" option. The list is now supplied byUserController::index()and forwarded by bothdialog.open()calls. The full-pageUsers/CreateandUsers/Editroutes were unaffected. -
A
FB.datePicker()value no longer drifts by a day on a form round-trip. A date-only string ("2024-03-10") from the server was parsed withnew Date(value), which JavaScript treats as UTC midnight; formatting it back for submission (toLocalDateStr) then reads it in the browser's local timezone, shifting the day in any timezone behind UTC. The date is now parsed component-wise (new Date(year, month - 1, day)) as local midnight, matching how it's serialized back. -
SkFormno longer shows two error toasts for the same failed request. The form raises its owndata_load_error/options_load_errortoasts with specific wording, but its internaluseApi()call also fired the composable's default generic toast for the same failure.SkForm'suseApi()instance now opts out with{ toast: false }; a consumer's ownuseApi()calls are unaffected. -
FB.checkboxGroup().optionsUrl(...)now actually fetches remote options. The dynamic-options watcher matched select-like fields against a hardcoded type list that omittedcheckbox-group, so a checkbox-group field configured withoptionsUrlsilently never fetched — it now uses the sameSELECT_TYPESset as everywhere else. -
A stale response from a dependent
optionsUrlfield can no longer overwrite a newer one. Rapidly changing the field that drives a dependent select's URL (typing in a search box, quick re-selects) could let an older request's response land after a newer one, showing outdated options. Each field now tracks a monotonic per-request counter; a response is applied only if it's still the latest request for that field, otherwise it's dropped silently (no options write, no error toast). -
A form/field marked read-only can no longer be reactivated by a field's own
.props({ disabled: ... }). Form-leveldisabled(from.permission()) or a field's own computeddisabledis now a floor rather than a default —.props({ disabled: false })can no longer unlock a read-only form, while.props({ disabled: true })still disables an otherwise-enabled field. -
Translatable field defaults on a create form now match the locales the field actually renders. The empty
{ locale: '' }seed used to readavailableLocalesfrom the Inertia page directly, which lists admin-UI locales; the field itself renders DB-backed content locales viaTranslatableInput. When the two lists diverged, the submitted payload could carry keys for locales the field never showed (or miss ones it did). Both now resolve through the samecore/localeshelpers. -
A dropped file that exceeds
maxFileSizeor the multi-filefileLimitis rejected instead of silently added.FB.fileUpload()'s drag-and-drop path now runs through the same validation as the file picker, deduplicates a file already in the keep-list, and revokes its blob object URL when removed instead of leaking it. -
A
<label for>on six wrapper-rendered field types (input-number,date-picker,select,multiselect,toggle-switch, andpasswordwith.feedback()) now targets a focusable element. These types render their PrimeVue control inside a non-focusable wrapper, so a plainlabel[for=key]pointed at nothing clickable; the inner control now receives${key}__controlvia PrimeVue'sinputId, and the label'sfortargets that id. -
Vertical
SkTabstab buttons no longer submit an enclosing form. The sidebar nav<button>had no explicittype, so browsers defaulted it totype="submit"— clicking a tab inside a<form>could submit the form instead of just switching tabs. It now setstype="button". -
A tab's
visible/disabledstate changing after mount now correctly drives the URL-synced active tab.useUrlTab()used to close over a fixed snapshot of the tab list taken at mount, so a tab that became visible later couldn't be reached via?tab=, and an active tab that became hidden or disabled left the UI pointed at a tab no longer in the list. The selectable list is now a reactive array kept in sync with the livevisible/disabledstate, so a newly visible tab is immediately selectable and a hidden or disabled active tab falls back to the first selectable tab. -
A disabled tab can no longer be activated from
?tab=, and a disabled first tab is no longer the param-less default.useUrlTab()now resolves both the URL parameter and the "no parameter" fallback against the selectable (non-disabled) tab list instead of the full list. -
Re-selecting the already-active tab no longer fires an Inertia visit, and
#hashnow survives a tab switch. Clicking the active tab again (or re-assigning it to its current value) used to still callrouter.visit(); it is now a no-op. Switching tabs also preserves any#hashpresent on the current URL instead of dropping it. -
Cross-page bulk selection on the Users table now honours the same
created_at_from/created_at_todate-range bounds the datatable renders with.UserBulkSelectionQuerynow applies dates throughDatatableQueryBuilder::applyCalendarDateRange(), the same helper the table's own query uses, so the bulk-resolved set can no longer drift from the visible set across timezone/DST boundaries. -
A selected row's id is sent to a bulk action exactly as the table displays it, without numeric coercion.
useDatatableSelection()'sexecuteBulkAction()no longer converts a numeric-looking id before posting; UUID/ULID and integer primary keys alike round-trip unchanged. -
The ID column on the API Token and API Client tables is sortable again.
ApiTokenController::dtApi()andApiClientController::dtApi()now allow-listidalongsidenameandcreated_at, so clicking the ID header no longer returns SpatieQueryBuilder'sInvalidSortQuery400. -
BulkActionRequestno longer rejects a cross-page bulk request that carries noids. The published request requiredids(min:1) even whenselect_all_filteredwastrue, contradicting the documented payload and 422-ing a host that callsuseDatatableSelection().executeBulkAction()in "all" mode with nothing selected on the current page (the shipped Users/Roles pages never reach that state — "select all filtered" is only offered from the bulk bar).idsis nowRule::requiredIf(! select_all_filtered); ids that are sent are still shape-checked (array,max:500, opaque strings). An unmodified copy is refreshed bysk:update. -
The shipped
lvntr-kit-frontendandlvntr-starter-kitskills now name the real auto-label key. Both told the agent an omitted.label()resolves fromsk-attribute.attributes.{key}in alang/{locale}/sk-attribute.phpfile that does not exist;FBfields and datatable column/filter labels resolve fromvalidation.attributes.{key}(lang/{locale}/validation.php). The FormBuilder guide also now states that in externalv-modelmodeinitialData(), a field's.default()anddataUrldata seed the internal form only and never populate the bound object. -
SkTabsicons are hidden from assistive technology and a checked tab announces its state. Tab icons in both layouts carryaria-hidden="true"(the label already names the tab), and the.checked()check mark — state, not decoration — is paired with visually hidden text (sk-common.completed, a new key in the EN/TR bundles) so a screen reader hears it instead of skipping it. -
Cross-page "select all filtered" now applies a literal
true/falsefilter value exactly as the table does. Spatie'sQueryBuilderRequestturns those two strings into booleans before a datatable filter ever runs, butBulkFilterSnapshot::normalize()passed them through as text — so a search fortrueresolved a bulk set matching the word "true" while the table had matched "1", and could reach rows the table never showed. The snapshot now performs the same coercion, and the word-search predicate moved intoDatatableQueryBuilder::applySearchWords(), the single helper the table'ssearchfilter and the shippedUserBulkSelectionQuery/RoleBulkSelectionQueryall use, so the two paths can no longer drift on how a value is split, escaped or coerced. -
A comma in the datatable search box no longer breaks the request. Spatie's query builder explodes every
filter[...]value on,before the table's search callback sees it, so a search such asAcar, Leventreached the callback as an array and the request failed with aTypeError(HTTP 500).DatatableQueryBuilder::applySearchWords()now re-joins the exploded value with the same delimiter and searches the text as typed; the cross-page bulk selection already applied the raw text, so both sides resolve the same set. -
The desktop search box's clear (×) control in
SkDatatableis now a real<button>, reachable and operable from the keyboard. It was a click-only<i>icon with no accessible name; it now carries the newsk-datatable.clear_searchlabel, which the mobile search popover's clear button uses as well (it previously announced "Close"). A test or stylesheet that targeted the control as an<i>element must target the button instead — thesk-dt-search__clearclass is unchanged. -
TranslatableInputlabels are now associated with their input. The label rendered beside the locale switcher (and in single-locale mode) carriesfor= the field key and the active input carries the matchingid, so clicking the label focuses the field — the same markup the regular form fields already use. When a single locale renders, a required field also marks that inputaria-requiredand pairs the decorative asterisk (nowaria-hidden) with an sr-only "required" text; with several locales the asterisk stays purely visual, becauseHasTranslatableRules::translatableRules()requires the default locale alone and makes every other localenullable— announcing each locale tab as required would be wrong. Atranslatable-editoris named througharia-labelledbyinstead offor: its editable node is a contenteditable<div>, whichlabel[for]cannot target. -
EditorInput'sidand ARIA attributes now sit on the node the user actually types in. Theidwas set on<EditorContent>'s wrapper<div>while Tiptap renders the contenteditable node inside it, so alabel[for]pointed at a non-labelable wrapper and assistive technology read no name, role or required state for the editor at all. The editable node now carries theid,role="textbox",aria-multiline="true"and the two newariaLabelledby/ariaRequiredprops. A stylesheet or test selecting the editor by#<field key>now matches the inner.sk-rte__contentnode instead of the.sk-rte__bodywrapper. -
encryption:keyno longer changes what an existingDATA_ENCRYPTION_PREVIOUS_KEYSentry means. The command normalised only the key it was retiring; entries already in the list were copied through verbatim. The list is read through phpdotenv (quotes stripped,${VAR}resolved) and written back unquoted, so an entry holding#,$or whitespace came back as a different key on the next boot —#opens a comment and truncates it. Every entry is now made env-safe (re-emitted asbase64:, decoding to the identical bytes) when its raw form cannot survive an.envline. -
encryption:keynow verifies its temporary.envwas written in full, and flushed to disk, before renaming it into place. A full disk makesFilesystem::put()return a short byte count instead of throwing, so a truncated body could replace a complete.env— on the first of the command's two writes, that body holds the only copy of the key being retired. A failed reopen,fflush()orfsync()aborts too: for this file durability is the safety property, and an unflushed write turns the two-write ordering back into a call order. -
encryption:keynow carries the.envowner and group onto the file it writes, and refuses to replace the file when it cannot.sudo php artisan encryption:keyover awww-data:www-data.envused to leave a root-owned file the web user could not read. Restoring ownership is attempted best-effort (only root can hand a file over) but the result is verified and a mismatch aborts — warning and continuing shipped an unreadable.envwhile reporting success. The mode restore is verified by the same rule: wider leaks the key, narrower locks out the service, both abort. Nothing is replaced on any of these paths. -
The kit's file-writing commands now refuse to run against the kit's own package checkout.
sk:install,sk:update,sk:upgrade,sk:publish,sk:eject,make:sk-domain,remove:sk-domain,env:syncandencryption:keyare covered. The package shipsvendor/bin/testbench, sovendor/bin/testbench sk:installinside a clone boots a real Laravel application whose base path is the Testbench skeleton living inside the checkout, with avendor/symlinked straight back to it. None of the already-installed markers fire on that bare skeleton, so the run was classified as a pristine first install and took the force-overwrite path — publishing stubs, mergingpackage.json, rewriting.env, generating domains and running migrations over the package sources. Each command now stops before the first byte is written: it refuses when the write target is the package root or sits inside it and that directory does not require the kit throughvendor/lvntr/laravel-starter-kit. A consumer application is never inside the checkout, and a scratch app nested under it that genuinely required the kit (atype: pathrepository, symlink included) still installs normally. The guard is about writing and only writing:--dry-run(sk:install,sk:update,sk:eject),encryption:key --show, and a--destination=pointing outside the checkout (sk:publish,sk:eject) all pass — a destination that does not exist yet is classified by its nearest existing ancestor, so naming an uncreated directory inside the checkout is not a bypass.--forcedoes not bypass it either: that flag means "overwrite the files I named", never "install the kit into the kit". The package's own Pest suite is exempt, since it drives these commands against the skeleton on purpose. -
sk:doctor's FileManager disk check now follows the disk FileManager actually writes to. It previously read afile-manager.diskconfig key thatconfig/file-manager.phphas never defined, so the fallback (filesystems.default) was all it ever checked — an installation that pointed Storage at a disk other than the app's default (media-library.disk_name, set bySettingsServiceProvider) had the check silently validate a disk FileManager doesn't use. It now readsmedia-library.disk_namefirst and falls back tofilesystems.defaultonly when that key is unset. A disk name that isn't defined underfilesystems.disksat all is now a FAIL (previously it reported the disk as "configured" and OK); a local/public disk whose root directory exists but is not writable by the web server user is now a WARN instead of being reported OK. -
The new
files.previewroute now answers HTTP Range requests.FilesystemAdapter::response()returns aStreamedResponse, which ignoresRangeentirely and replies200with the whole body — so onceFileItemDTOstarted resolving to this route, the inline<video>/<audio>player inFilePreviewModalcould no longer seek, and Safari refused to start playback at all. On alocal-driver disk both serving actions now return aBinaryFileResponse, which doesAccept-Ranges/Content-Range/206inprepare(); remote drivers, which have no real filesystem path, keep the streamed response. -
Images inserted through the editor are published again.
EditorInput.vuewrites the uploaded (or picked) file's URL straight into the persisted document as<img src>, and that content is rendered to visitors who never authenticate — so pointing it at the session-gated preview route broke every published image.FileItemDTOnow carries a second field,public_url:Media::getUrl()when the backing disk is declared'visibility' => 'public', andnullotherwise. The editor embedspublic_urland falls back to the gatedurlonly when there is none. This adds no exposure — a public disk serves those bytes to anyone with the path regardless of what the API returns — and the file browser itself still uses the gatedurlthroughout. On a private diskpublic_urlisnullby design: there is no link to publish. -
sk:installno longer silently republishes over an already-installed app. A leftoverinstall-progress.jsoncheckpoint — written by any prior run that ended incomplete, e.g. an unreachable database — disabled both the existing-app-markers guard and the confirmation prompt on every later plain re-run, regardless of whether--resumewas passed;stepAlreadyCompleted()already required an explicit--resumeto actually skip a step, so nothing was really being resumed, just unguarded. A newconfirmReinstall()gate now asks before republishing whenever the hash registry (storage/starter-kit/hashes.json) already exists and the run isn't an actual--resume; it fails closed under--no-interactionunless--forceis passed, and a stale checkpoint alone can no longer suppress either guard. -
A custom permission with two dots in its name (e.g.
system.health.view) is no longer split on the wrong dot. BothGroupedPermissionsQuery(the Roles form's permission matrix) and_01_RolePermissionSeedersplit a permission name on the first dot to separate resource from ability, sosystem.health.viewbecame resourcesystem/ abilityhealth.viewinstead of resourcesystem.health/ abilityview— the matrix showed a nonsense ability label under the wrong resource. Both now split on the last dot instead. Thedeveloperandapipermission groups and thefiles,api-clients,api-tokens,dashboard,system.healthresource labels and theviewability label — previously falling back to their raw permission-name segments in the UI — are now inpermission-resources.phpand the EN/TRsk-rolelang files. -
The Roles form no longer hides a validation error on an inactive tab. Vertical
SkTabsmounts only the active panel; the Basic Info tab (name/display_name/color) is not the default active one, so a required-field error there after a failed submit rendered nowhere and the operator saw no feedback at all. The form now switches to the tab holding the errored field. -
A fresh install no longer sends a successful login to a 404.
FortifyServiceProvidernever overrode Fortify's own post-login redirect target,config('fortify.home')(/home) — a route this kit never registers, since the real landing page isdashboard.index(/dashboard). The published provider now setsconfig(['fortify.home' => '/dashboard'])inboot(); an existing install that customised its own copy is unaffected until it re-publishes.
2026-08-25 — v13.6.16
Fixed
- A datatable no longer renders empty because a neighbouring table's sort was left in the page URL.
sortis a page-global query parameter, so on a page hosting several tables (tabs, side-by-side panels) the table that mounted second read the first one'ssortout of the URL and asked its own endpoint for a column that endpoint never allowed —Spatie\QueryBuilderanswers with HTTP 400 (InvalidSortQuery), so the table came up blank. A bookmarked link failed the same way once a column had been renamed or dropped.SkDatatablenow validates a restored sort key against its own columns before using it — the id column included, since it is sortable without appearing incolumns, and this route's persisted column order too, so a column only the server publishes (a hiddenupdated_at, say) still restores its sort once the user has enabled it. A URL carrying a foreign sort is treated as another table's URL and is ignored whole —page,per_pageand the filters with it, because reading half of it would only open this table on the neighbour's page number. A stale key coming back from the per-route session blob is dropped the same way, while a sort the table does own is still restored from both sources.
2026-08-24 — v13.6.15
Changed
- The default panel no longer ships dead controls. A fresh install's header carried four user-menu entries with no
commandbehind them (account settings, notification preferences, change password, help) plus notification and message popovers filled with invented orders, payments and contacts — hardcoded Turkish strings in an otherwise bilingual kit, with a permanently lit badge and a "mark all read" action that did nothing. All of them are gone; the profile link, the language submenu, logout, the appearance popover and the system-admin developer popover stay. The Dashboard'sExport,New Report,View AllandView Reportbuttons, which were equally inert, were removed too, and the demo dashboard now opens with a translated banner (sk-common.demo_banner) stating that its metrics are sample content. The charts, KPI cards and tables are unchanged, so the screen still shows what the kit's components can do — it just no longer presents fabricated business data as if it were the consumer's own. Translation keys for the removed menu entries were left in the language files. - The frontend lint gate is enforced instead of merely running.
npm run lintexited 0 while reporting 2,708 warnings across 33 files (2,473vue/html-indent, 231vue/max-attributes-per-line, four other template-formatting findings), so CI's lint step could never fail and a genuinely new warning was invisible in the noise. The whole baseline was mechanically fixed witheslint --fix— formatting only, no behavior touched. The gate is enforced through a newlint:ciscript (--max-warnings=0) that the kit's own CI runs; the consumer-facingnpm run lintstill reports warnings without failing, so warnings in an installed app's own code do not break its lint step or pipeline. - Inertia pages are resolved lazily, so the first visit no longer downloads the whole panel. Both page globs in
resources/js/app.tswere eager, which put all 54 Vue pages — the file manager, the Tiptap editor, every settings screen — into the initial bundle even for a visitor sitting on the login form; a catch-allvendorchunk invite.config.tsthen pinned those feature dependencies to that same payload. The globs are now lazy and hoisted to module scope,resolvereturns the matched loader's promise (Inertia v3 awaits an async resolver on the client and in SSR alike, so the previous "SSR needs a sync resolver" constraint no longer holds), and the catch-all chunk is gone so single-page dependencies fall behind their own dynamic-import boundary. The measured initial payload drops from 652.2 kB to 390.4 kB gzip (−40%), split across 121 chunks with 54 dynamic imports off the entry. App-over-vendor page precedence, thePage not founderror and the language globs (which stay eager) are unchanged.scripts/ci/check-bundle-budget.mjsnow gzips the entry's static import closure and fails CI above 500 kB, so a regression is caught rather than shipped. - Two stale leftovers are gone from the shipped scaffold.
app.blade.php's<title>fallback readStarter Kit 12— a version number that went stale at every release, and one that only ever surfaced whenapp.namewas unset; the fallback is now justStarter Kitand carries no version. The Files page separately importedMyShareLinksDrawerand rendered it underv-if="false", so ~190 lines of a component still waiting on its backend endpoint were pulled into the Files page's own chunk and downloaded by anyone opening that page, without ever being reachable. That import and its dead state (sessionLinks,drawerVisible,drawerMediaId,onShareRevoked) are removed, so the Files page no longer carries it. The component file stays where it is — the recursive vendor page glob still sees it and a build still emits a (now never-fetched) chunk for it — and a comment records what to re-wire onceGET /file-manager/share?media_id=Xexists.
Fixed
- Install docs no longer steer new projects onto an ancient release. The documented flow required the package with
:^13.0, a range wide enough to includev13.0.1— the last release that still acceptedspatie/laravel-activitylog:^4.9. Becauselaravel/laravelitself only requires PHP 8.3 while this kit (andactivitylog:^5.0) requires PHP 8.4,composer create-projectsucceeds on PHP 8.3 and Composer then resolves quietly down tov13.0.1instead of reporting the platform mismatch;composer updateafterwards correctly answers "nothing to update". README and the install guides now require:^13.6, so Composer fails with the real reason, and they state the PHP 8.4 floor explicitly alongsidecomposer why-not lvntr/laravel-starter-kit 13.6.14for diagnosing an unexpected resolved version. The Turkish README and thesk:upgraderemediation hint were missed by that pass and still printed:^13.0; both now match, and the Turkish README carries the same PHP 8.4 / Node 20.19+ warning as the English one. - The production CSP no longer blocks previews served from cloud storage.
img-srcallowed only'self' data: blob:while the kit supportslocal,s3and DigitalOcean Spaces disks and the FileManager hands the browser signed URLs on the bucket's own origin — so on a remote disk every preview, and every download the frontend fetches, was blocked by the policy the kit itself sets.SecurityHeadersnow derives the origins of the media-library disk and the public disk (a diskurlsuch as a CDN base, an s3endpointplus its*.hostbucket-subdomain form, or the region/bucket pair for plain AWS) and appends them toimg-src, the newmedia-src, andconnect-src. Additional origins — a remote image embedded in the welcome message, for instance — go instarter-kit.security.csp_extra_origins, which acceptshttp(s)origins only. A response that already carries a CSP is still left untouched, andlocalstill gets no policy at all. sk:doctorlog checks report the effective setting on a config-cached app.LogChannelCheckandLogStackCheckreadenv()directly, and onceconfig:cachehas run.envis not loaded, so both reported their defaults rather than what the application actually uses — on the exact deployments where a doctor run matters most. They now readlogging.defaultandlogging.channels.stack.channels.LogStackCheckadditionally judges only the channel that is actually active: it readlogging.channels.stackunconditionally, so an app onLOG_CHANNEL=dailywas warned about a stack its log records never reach, whileLOG_CHANNEL=single— genuinely unrotated — passed as OK. The check now resolveslogging.default, expands it into its member channels when it is a stack, and warns when any resolved channel uses thesingledriver, pointing at whichever knob actually reaches the offending channel:LOG_STACKonly when the active channel is the framework's ownstack,logging.channels.<name>.channelsfor a differently named stack, andLOG_CHANNELotherwise. That expansion mirrorsLogManager::createStackDriver()rather than approximating it: achannelsvalue written as a string (LOG_STACK=single,daily) is exploded on commas instead of being read as one channel name, and members are resolved recursively, so asinglenested one stack deeper is no longer reported as rotated. A configuration cycle terminates on the resolution path instead of recursing.- A cached config no longer silently disables Inertia SSR. When the consumer has not published
config/inertia.php, the service provider setinertia.ssr.enabledfromenv('INERTIA_SSR_ENABLED')on every boot.config:cachecaptures that override correctly while.envis still loaded, but the same code then re-ran on each cached request withenv()returning null and stomped the cachedtrueback tofalse. The override is now skipped when the configuration is cached. Note for existing installs: an app that setINERTIA_SSR_ENABLED=trueand ranconfig:cachewas in fact still rendering client-side, and SSR genuinely engages after this fix — make surephp artisan inertia:start-ssris running. If it is not, Inertia degrades to client-side rendering rather than erroring (HttpGateway::dispatch()returnsnullwhen the bundle is missing or the connection fails, unlessinertia.ssr.throw_on_erroris enabled). - An HTTPS asset URL is no longer rewritten to a protocol-relative one. The mixed-content guard in
SettingsControllerandSettingsDefaultsQuerystripped the scheme from bothhttp://andhttps://public-disk URLs, so anhttps://asset opened over an HTTP page downgraded to HTTP — the opposite of what the "never a downgrade" comment claimed. Onlyhttp://URLs are rewritten now; anhttps://URL is never mixed content and is passed through as-is.
Security
- Twenty-five of the kit's own routes are no longer ungated by omission.
CheckResourcePermissionderives a permission from a route's name, and a route with no name, fewer than two name segments, or an action segment outside the middleware's ability map resolves to nothing — previously that meant the request passed through in total silence. Twenty-five routes the package itself registers fell into that gap: the fivesettings.contentLanguages.*endpoints, fifteensettings.update.*/settings.upload.*/settings.delete.*writes,settings.testMail,roles.syncPermissions, and theroles.bulk/users.bulkendpoints. They are now pinned to a permission by a route-name contract that lives inside the package, so an existing installation gets the fix fromcomposer updatealone — the route filessk:installcopied into your app are untouched. Every mapping was chosen to be behavior-neutral: the settings routes already enforced the same permission through an explicitcheck.permission:argument, androles.syncPermissionsis additionally restricted tosystem_adminby its own controller.roles.bulkandusers.bulkare declared exempt rather than mapped: the ability they require depends on the action named in the request body, andBulkActionDispatcheralready authorizes every item against the handler's own ability, so any static route-level mapping could only over-deny (.delete,.updateand.readeach break a different legitimate role).system-health.runis likewise exempted by name, because its controller already callsGate::authorize('system.health.view')and its route group is restricted tosystem_admin. A route that carries its own explicitcheck.permission:<permission>argument is also no longer double-judged by the parameterless group pass.
Added
-
sk:doctorgained anunresolved-routescheck. It lists every route in the application, including consumer-added ones, whose permission cannot be derived byCheckResourcePermission— the same routes that currently pass on a logged warning rather than a resolved permission. Runphp artisan sk:doctor --only=unresolved-routesto see them; each one is fixed by a<resource>.<action>route name, an explicitcheck.permission:<permission>argument, or a listing under the newstarter-kit.permissions.unrestricted_routesconfig key. -
Two new
starter-kit.permissionsconfig keys.allow_unresolved(envSTARTER_KIT_ALLOW_UNRESOLVED_ROUTES, defaulttrue) controls whether a route whose permission cannot be resolved at all is allowed through with a logged warning or denied; unlike the existingallow_unmapped, it keeps applying in production once flipped, because an unresolved route is a structural route/ability-map mismatch rather than a per-host data gap.unrestricted_routeslistsStr::isroute-name patterns that are deliberately permission-free and are exempt from both the check and the doctor warning. -
A new project installs fail-closed on unresolved routes; an existing one is untouched.
sk:installwritesSTARTER_KIT_ALLOW_UNRESOLVED_ROUTES=falseinto the.envit creates. A fresh app has no legacy route to grandfather in, so it starts strict and its first ungated route surfaces during development rather than in production. Nothing carries that value into an app that already exists:ensureEnvFile()copies.env.examplewholesale only on a first install, and the re-install path now skips a smallFIRST_INSTALL_ONLY_ENV_KEYSlist, so re-runningsk:installon an installed app does not add the key either.sk:updateandsk:upgradenever touch.envat all. There is no release in which an existing installation starts denying on its own — theallow_unresolveddefault staystruefor anything that does not set the key, and an existing app opts in by writing the line itself oncesk:doctor --only=unresolved-routesis clean. See the upgrade guide for the ordered remediation path.
2026-08-15 — v13.6.14
Security
- Activity logs no longer retain credentials. Fillable and unguarded model logging excludes
password,remember_token,two_factor_secret,two_factor_recovery_codes, and attributes ending in*_tokenor*_secret; a password-only update now creates no activity row. The Activity Log UI also masks these keys in legacy rows. The new irreversible data migration and the idempotentsk:redact-activity-secretscommand recursively remove them from bothattribute_changesandproperties; back up the database before migrating and inspect any undecodable JSON rows reported by the command. Models may extend the deny list throughsensitiveLogAttributes(). The data migration ships inside the package (database/migrations/), socomposer updateplusphp artisan migratedelivers it withoutsk:update, and it scans every row so a differently-cased key is not skipped on a case-sensitive JSON collation.sk:doctorgained anactivity-log-secretscheck that FAILs while credential-bearing rows remain; it is a bounded read-only probe over the first 500 rows by primary key, identical in cost on every driver, and it decides in PHP rather than through a SQL key-name filter, so aPassword-cased key cannot be skipped by the column's collation. Its messages state what was measured — a finding over a larger table is reported as a floor ("at least N") and a clean bounded result names its window rather than clearing the table;sk:redact-activity-secrets --dry-run --allremains the exhaustive count (--allis what turns off the SQL key-name prefilter). - Authentication settings now fail closed before Fortify registers its routes. Registration, password reset, and forgot-password requests return 403 when their settings are disabled. A two-factor challenge is claimed with an atomic add-if-absent cache entry, so exactly one of two concurrent redemptions can mint a token —
Cache::pull()reads as a claim but is a separate get and forget, which rate limiting narrows without serializing. Recovery-code use is additionally protected by a database row lock. - User-controlled datatable labels are escaped before reaching
v-html. Role display names, activity-log causers, and API-client grant types can no longer inject markup. Frontend dependency locks were also refreshed with non-breaking updates including axios 1.19.0, Vite 7.3.6, esbuild 0.28.2, form-data 4.0.6, shell-quote 1.9.0, and undici 7.29.0; both production and fullnpm auditnow report 0 vulnerabilities, and frontend CI enforcesnpm audit --audit-level=high --omit=dev.
Added
sk:doctorgained apermission-matrixcheck.config/permission-resources.phpis user-owned andsk:updatenever writes to it, so resources and abilities the package adds in a later release never reach an existing installation — the first sign of which is usually a 403 on a screen that used to work. The check diffs the shipped matrix against the one the application has loaded and reports what is missing (files.update,files.delete, and so on), then points atsk:seed-permissions. It is one-directional: resources the consumer added themselves are never reported. It compares abilities by backing value, so an entry written as aPermissionEnumcase counts the same as the string, and it expands anull(all abilities) declaration on the package side from the abilities the package itself ships — never from the consumer's extensiblePermissionEnum— while treating one on the application side as covering everything.
Fixed
sk:updateno longer overwrites a consumer-extendedPermissionEnum.app/Enums/PermissionEnum.phpis package-owned and refreshed on every update, but it is also a backed enum with publicfor()/allFor()helpers, so adding a project ability (case Approve = 'approve';) is the obvious thing to do — and until now that case was copied over whenever the file merely differed from the stub, with no registry check, no backup, and nothing in the summary. The copy is now guarded by the same install-time hash every other consumer-owned file already uses: a provably untouched file is refreshed, an edited one is preserved and reported separately with its merge instructions, an untracked one goes to the existing interactive prompt instead of being assumed unmodified, and--forcestill overwrites.- FileManager requests now work on sub-directory installations.
withBasePath()is idempotent anduseApi.request()applies it centrally, preventing missing or doubled base paths. - Dashboard and
SkDatatableno longer access browser-only globals during SSR.
Breaking
- FileManager context authorization now uses
read,create,update, anddeleteinstead of collapsing mutations intowrite. The built-inglobalcontext maps these one-to-one tofiles.read,files.create,files.update, andfiles.delete; unknown abilities fail closed. A role that previously held onlyfiles.createloses delete and empty-trash access, while a role that held onlyfiles.updateloses read access. Grant the specificfiles.*abilities required by each role, then runphp artisan sk:seed-permissions. Consumer context closures must handle the four new names and will never receivewrite; see the upgrade guide.authorizeWrite()remains as a deprecated alias toauthorizeUpdate()for direct callers. - Disabled two-factor authentication now removes Fortify's 2FA routes. They return 404 instead of remaining registered. The 2FA management endpoints also now carry
password.confirmbecausefortify-options.two-factor-authenticationis set before route registration; direct API consumers must complete that confirmation round-trip.
2026-08-15 — v13.6.13
Changed
- The kit is now MIT licensed (previously PolyForm Noncommercial 1.0.0). Commercial use is allowed without restriction — you can ship the kit inside closed-source and paid products, as long as the copyright and permission notice stay in place.
- Behavior change: API Resource dates are now ISO-8601 values with an offset, not preformatted display strings. This gives the frontend one parseable instant to format consistently in the user's resolved timezone.
format_date()itself is unchanged and stays compatible for existing Blade, mail, export, and other display callers. - Behavior change: storage and display timezones are separate. Keep
APP_TIMEZONE=UTC;display_timezonenow reads the newAPP_DISPLAY_TIMEZONEvariable instead ofAPP_TIMEZONE. Existing installs must add the variable and runphp artisan sk:upgradeso its safe, repeatable config rewrite updatesconfig/app.php.sk:doctor --only=timezone-storagereports a failure if storage is not UTC. - MySQL/MariaDB connection sessions are now pinned to UTC.
sk:installandsk:upgradeadd literal'timezone' => '+00:00'entries to existingmysql/mariadbarrays inconfig/database.phpwithout overwriting consumer values or touching other drivers. Existing installations may already carry offset application-writtenTIMESTAMPdata; it stays offset until the one-time conversion in Timezones is completed.DEFAULT CURRENT_TIMESTAMPcolumns move in the opposite direction and must be excluded.sk:upgradewarns and asks before changing a non-UTC session with data (unattended runs without--forceskip), but never converts rows.sk:doctor --only=timezone-storagenow detects a non-UTC MySQL/MariaDB session, includingSYSTEM, and warns rather than passing when the session cannot be read. - Users can choose their own display timezone, and datatable date filters now respect it. A blank user preference means “follow the General site setting” and is different from explicitly choosing UTC. The shared user → site → app → UTC fallback applies across backend and frontend formatting; calendar-date filters use DST-correct, half-open UTC ranges without wrapping the indexed column.
2026-07-25 — v13.6.12
Added
- The kit's AI skills now work with Codex as well as Claude Code.
sk:installpublishes the three skills to.claude/skills/and mirrors them to.codex/skills/, which the OpenAI Codex CLI reads natively. Edit the.claudecopies to customize — the.codexmirror is regenerated on everysk:install/sk:updateand never touches your own skills in that directory.sk:install --without-ai-skillskips both trees;sk:update --without-ai-skillskips regenerating the mirror for one run.
Changed
- The shipped AI skills were brought up to date with the current kit (they still described the pre-v13.6.0 layout): vendor-first architecture,
sk:ejectand the install-time User/Role eject,sk:doctor, the fullsk:publishtag list,make:sk-domain --with=extras, the realsk:updateoverwrite rules, the current composables and FormBuilder field types, SkForm's safety guards, and the theme system. Skill bodies are now in English (Turkish trigger keywords retained) so one skill set serves both assistants.
2026-07-22 — v13.6.11
Fixed
- Uploading a file no longer shows it twice, and the "unsaved changes" warning finally clears on forms with file fields. After saving a form with an upload field, the file you had just picked stayed in the form alongside the copy that had already been stored — so the same image showed up as two entries. The same leftover file also kept the form permanently marked as changed, which meant the unsaved-changes banner and the "are you sure you want to leave?" prompt never went away, no matter how many times you saved. Saving now refreshes the stored file list from the server and clears the picker, so you see one copy and the form goes clean — and the file you just uploaded is no longer at risk of disappearing when you save the form a second time.
2026-07-21 — v13.6.10
Fixed
- The "unsaved changes" warning no longer sticks around after you save. On forms that submit themselves, saving worked but the form still considered itself dirty — so the unsaved-changes banner stayed visible and closing or leaving the page kept asking you to confirm, even though everything had already been saved. Saving now marks the form clean straight away. If you keep typing while the save is still running, those newer edits weren't part of the save — the form stays marked as unsaved for them, so nothing is silently lost. Create forms that clear themselves after saving still clear as before.
2026-07-08 — v13.6.9
Security
- Unmapped permissions are now denied on staging/demo, not just production. The
CheckResourcePermissionmiddleware used to let a request through whenever the required permission was missing from the database on any non-production environment — so a public staging or demo host could silently expose an endpoint whose permission row was forgotten. It now denies everywhere exceptlocal(local still warns and allows for dev convenience). If you deliberately want the old behavior on non-production hosts, setSTARTER_KIT_ALLOW_UNMAPPED_PERMISSIONS=true. See UPGRADE.md. - Permission lookups are now Octane-safe — the seeded-permission list is cached for a short time (60s) instead of for the whole worker lifetime, and both
php artisan sk:seed-permissionsand the Roles screen's permission sync clear it immediately, so newly seeded permissions take effect right away under Octane.
Changed
- Datatable column visibility/order preferences moved from
sessionStoragetolocalStorage. If you've customized which columns are shown/hidden or reordered in aSkDatatable, that preference resets once after upgrading — no data loss, purely cosmetic, and you can just re-set it.
2026-07-04 — v13.6.8
Quality & UX sprint
A broad quality-control pass: audit-log coverage, install/upgrade DX, accessibility, and a login-throttle security fix. One published-file change needs sk:update — see UPGRADE.md.
Security
login_throttle = '0'no longer fully disables the web login rate limiter — it now swaps to a deliberately generous floor limiter instead of removing throttling entirely, so no admin setting can leave web login unthrottled.
Added
- Audit log now covers role/permission changes, Settings, API Clients/Tokens, share links, and Content Languages — these were previously invisible outside a log file (or not logged at all); they now show up in the ActivityLog admin screen. Setting values are never logged, only which keys changed.
sk:installcan resume after a failure —php artisan sk:install --resumepicks up exactly where an interrupted install left off, and a failed step now prints a clear message instead of a raw stack trace. A Node.js version check runs up front so an old/missing Node produces a warning, not a cryptic crash mid-install.sk:doctorchecks Node.js version and whether a queue worker is actually running, and no longer silently reports "OK" when it can't detect a cron heartbeat. Individual checks now time out instead of being able to hang the whole command.sk:ejectnow asks for confirmation before ejecting a domain (unless you pass--force/--dry-run/--no-interaction) — ejecting means the domain stops receiving kit updates, so it's no longer a silent one-way door.- Datatable is keyboard-accessible — sortable headers, the search-clear button, and filter-remove buttons all work with Tab + Enter/Space now, and an empty table tells you whether that's because there's no data or because your filter matched nothing (with a one-click "clear filters").
- Forms are safer to use — double-submitting is now impossible, leaving a form with unsaved changes prompts a confirmation, a failed data/option load shows a retry option instead of failing silently, and required fields are announced to screen readers.
- FileManager shows overall upload progress across multiple simultaneous uploads, and the image lightbox supports arrow-key navigation between images.
Changed
- CI now fails the build on lint errors instead of only warning.
--no-interactioninstalls get a fresh random admin password (printed at the end) instead of the old fixedpassword.- A handful of backend consistency cleanups (centralised 422 error mapping, shared definition-controller logic, unified definition cache invalidation) with no visible behavior change.
2026-07-03 — v13.6.7
Rich-text editor's empty area is now clickable
A single targeted CSS fix — no API or setup change.
Fixed
- Clicking below the last line of text in the rich-text editor did nothing —
EditorInput.vuesetsminHeightas inlinemin-heighton the editor's wrapper, but the inner ProseMirror element usedheight: 100%. Percentage heights only resolve against a parent with a definite height, so ProseMirror only grew to fit its own content — the rest of the visually-tall box sat outside the realcontenteditableregion, so clicking or typing there was ignored. The wrapper is now a flex column and ProseMirror usesflex-1instead, so the editable area fills the whole configured height.
2026-06-20 — v13.6.6
Activity log accepts UUID and numeric subjects
A single targeted database fix — no API or setup change.
Fixed
sk:seed-permissionsno longer crashes with a uuid cast error — the activity-log table created its polymorphicsubject_id/causer_idcolumns as nativeuuid. But the kit logs activity onUser(uuid key) and on the SpatiePermission/Rolemodels (numeric/bigint keys), so seeding permissions failed withSQLSTATE[HY000] 4078: Cannot cast 'bigint' as 'uuid'. A new migration widens both id columns tochar(36), which stores a 36-char uuid and any numeric id alike — one polymorphic column now fits every audited model. The migration converges every prior state (native uuid, legacy bigint, legacy char(36)) tochar(36); existing apps pick up the fix on the nextphp artisan migrate.
2026-06-14 — v13.6.5
Translation bundle now ships with the package
A packaging fix — no API or setup change.
Fixed
- Fresh installs no longer show raw translation keys — the pre-compiled kit translation bundles (
resources/js/lang/php_{en,tr}.json) were listed in.gitignore, so they never entered Git and were absent from the Composer dist (which is agit archiveof tracked files only). A freshly installed app received only the build script, not the bundles, so every kit i18n key (sk-menu.*,sk-setting.*, …) rendered as its raw key instead of the translated label. The two bundles are now tracked and shipped. The consumer does not build the package, so — unlike the consumer-built theme bundle — these must be committed to reachvendor/; the build script's own docs already specified "COMMITTED and shipped".
2026-06-14 — v13.6.4
Datatable inline filter dropdown fix
A single targeted fix — no API or setup change.
Fixed
- Inline filter dropdown no longer clipped — a select filter's inline pill menu was rendered as an
absoluteelement inside the table card, so a long option list was cut off at the card / scroll-containeroverflowedge. The menu is now teleported to<body>as a fixed overlay (the same approach PrimeVue's ownSelectuses viaappendTo): it is positioned from its trigger, re-aligns on scroll/resize, caps atmin(60vh, 420px)with its own scroll, and closes on outside-click / Escape. Thepanel-placement popover variant is unchanged (it already rides PrimeVue's overflow-visible portal).
2026-06-13 — v13.6.3
Admin UI polish
A round of admin-panel UI refinements — no API or setup change.
Changed
- Aura sidebar footer is a version pill — the aura-theme sidebar footer became a single-row pill card: a green status dot and the app name on the left, the version (monospace) pushed to the right edge, with the same left/right inset as the nav item cards above it. Scoped to the aura theme only; the
maintheme footer is unchanged. - Account menu drops the external-link arrow on plain links — the topbar user/account dropdown no longer shows the hover
↗arrow on ordinary link items (My Profile, Account Settings, Change Password, Help, Logout). Submenu items keep their chevron and the active language keeps its check mark. - Datatable filter popover is panel-only — the funnel button and its popover now appear only when a filter uses
panelplacement;inline()filters are no longer duplicated inside the popover. The Activity Logs page now renders all three filters (Event, Model, Date) inline in the toolbar, so its funnel/popover is gone entirely.
Fixed
sk:install/sk:updatebanner version label — the installer/updater header now readsv13.6.x(was the stalev13.5.x). Cosmetic only; the historicalv13.5.0+behaviour notes are unchanged.- Datatable
value-mode tags resolve i18n keys at render time —tagLabels()values are translated when the cell renders, not when the builder runs. The builder is built in a page's<script setup>body before the i18n bundle loads, so an eagertrans()there froze the raw key (the Content Languages table showedsk-content-languages.directions.ltrinstead of "Left to right (LTR)"). Literal (non-key) labels are unaffected sincetrans()returns them unchanged. - Content Languages form — spurious required asterisks removed — FormBuilder fields are required by default, so
flag,fallback_codeandsort_order(allnullableserver-side) drew a red*. They are now.optional(), matching their validation rules;code,name,native_name,directionkeep the asterisk.
2026-06-13 — v13.6.2
Admin panel layout & form alignment fixes
A batch of main-theme polish fixes for the admin panel — no API or setup change, just visual correctness.
Fixed
- Roles form basics is a responsive 3-column grid — the name / display-name / tag-color fields now sit side by side (
FB.form().cols(3)) and stack vertically on small screens instead of always being full-width. - Permissions table is flush to its card — the roles permission matrix uses the
SkCardflushprop, so its row borders reach the card edges instead of floating inside the body padding (cells keep their own inner padding). - Translatable field inputs align with siblings — the locale tab pills (
TranslatableInput) were taller than a plain label and pushed their input down; the pills now match the plain-label height, so every input in a grid row starts on the same line. - Sidebar no longer crushes rows — with multiple menu groups expanded the nav compressed its children before scrolling; direct children are now
shrink-0, so overflow scrolls instead of overlapping. - Sidebar footer aligns with the page footer — the sidebar footer height is pinned to
h-footer(56px) so its top border lines up with the page footer's border at the bottom of the screen.
Changed
- Security settings sub-tab "Cloudflare Turnstile" → "Bot Protection" — the security sub-tab label (EN/TR) and the related
SecurityTabsection now use the provider-neutral name.
2026-06-13 — v13.6.1
sk:update self-heals stale component imports
A single targeted fix — no API or setup change.
Fixed
sk:updateno longer leaves stale imports behind after a component moves to vendor — when a component moves out of stubs into@lvntr/components, its old local copy is force-deleted, but user-customized pages that still imported the deleted local path were left untouched and broke the Vite build with anENOENTload-fallback error (e.g.@/components/Auth/TurnstileWidget.vue).sk:updatenow rewrites such stale import specifiers to the vendor path (@lvntr/components/ui/TurnstileWidget.vue) acrossresources/js, completing the migration that started in v13.6.0 on existing consumers' customized Auth pages (Login,Register,ForgotPassword).
2026-06-13 — v13.6.0 (continued)
Vendor-first Phase 2 — Settings-tab controllers, Definitions/Media, ContentLanguage
Phase 2 finishes the vendor-first move started in Phase 1 by relocating the remaining controllers that back the vendor Settings tabs plus two API/Service controllers, and fully vendorizing the ContentLanguage domain. The Vue and migrations were already vendor — this is a PHP-layer-only move. Fresh installs receive no app copies of these files; existing installs are migrated by sk:update under the same hash guard.
Changed
- Vendor-first HTTP layer for ApiClient, ApiToken, SystemHealth, ContentLanguage, Definitions (Api + Service), and MediaUpload — these controllers (plus their FormRequests / API Resources where present) now live in
Lvntr\StarterKit\Http\...and are aliased back to theirApp\Http\...FQCNs for backward compatibility. An app copy disables the alias automatically so your customisation continues to win. Route names, permission keys, and the Passport secret single-reveal are unchanged. - ContentLanguage domain vendorized —
Actions/DTOs/Queriesmoved toLvntr\StarterKit\Domain\ContentLanguage\. TheApp\Models\ContentLanguagemodel stays app-owned (never aliased — keeps policy discovery + route-model binding intact); vendor code references it byApp\FQCN.
Added
sk:ejectgains five entries —SystemHealth,ContentLanguage,Definitions,MediaUpload, and a full-HTTP-layerApiClient(which also ejects theApiTokencontroller/request/resource). The ejectable domain count rises from 10 to 14.
Migration
Run composer update lvntr/laravel-starter-kit && php artisan sk:update. See docs/UPGRADE.md (v13.5.11 → v13.6.0, "Behavior-module HTTP layer moved to vendor — Phase 2").
2026-06-13 — v13.6.0 (continued)
Behavior-module HTTP + Vue layers moved to vendor; sk:eject supports Files
Five built-in admin modules — Files, Logs, ActivityLogs, ApiRoutes, Settings — now run their controllers, FormRequests, and Vue admin pages entirely from the vendor package. Fresh installs receive no app copies of these modules. Existing installs are migrated by sk:update under a hash guard (unmodified copies removed; modified copies preserved and reported). Vue migration additionally requires the @lvntr/pages vendor-fallback glob in app.ts.
Added
sk:eject Files— ejects the FileManager admin Vue pages (resources/js/pages/Admin/Files/) into your app for UI customisation. The FileManager backend (controller, FormRequests, route-registry infrastructure) always stays vendor-managed; only the Vue layer is copied. Reverting deletes the copied pages and the vendor copy resumes viaapp.tsfallback.- Vendor-first HTTP layer for Logs, ActivityLogs, ApiRoutes, Settings — controllers and FormRequests now live in
Lvntr\StarterKit\Http\...and are aliased back toApp\Http\Controllers\Admin\*for backward compatibility. Anapp/Http/Controllers/Admin/SomeController.phpfile in your app disables the alias automatically so your copy continues to win. - Group-atomic migration in
sk:update— vendor-first modules are migrated per layer (PHP and Vue independently). If any file in a layer is modified, the entire layer is preserved. No half-deleted module is ever produced.
Changed
sk:ejectmanifest extended —Filesdomain added (Vue-only:backend: ''). The available domain list in the command signature now includesFiles.
Migration
Run composer update lvntr/laravel-starter-kit && php artisan sk:update && npm run build. For a customised module, sk:update preserves it and reports it; run sk:eject <Module> to take full explicit ownership. See docs/UPGRADE.md (v13.5.11 → v13.6.0, "Behavior-module HTTP + Vue layers moved to vendor") for the three-scenario guide.
2026-06-11 — v13.6.0 (continued)
Install-time domain eject for User + Role
Fresh installs now automatically eject the User and Role domain runtime into app/Domain/. These are the two domains most often customised, so they arrive as project-owned files without any extra step.
What changes on a fresh install
app/Domain/User/andapp/Domain/Role/are created with backend classes (Actions, DTOs, Queries, Events, Listeners) rewritten to theApp\Domain\namespace.DomainServiceProviderreceives theEvent::listenbindings for the six audit events so activity logging continues without interruption.- Future
composer updateruns do not touch these directories — you own them.
Opting out
php artisan sk:install --without-eject
Both domains remain vendor-resident and resolve via class_alias. You can run sk:eject User / sk:eject Role manually at any time.
Reverting after install
Delete app/Domain/User/ and app/Domain/Role/, remove the injected Event::listen lines from app/Providers/DomainServiceProvider.php, and run composer dump-autoload.
Existing installs
No change. The eject step runs only when storage/starter-kit/hashes.json does not yet exist (first install). On existing installs the registry is already present so the step is skipped. Existing projects are unaffected.
New flag on sk:eject
sk:eject gains a --skip-autoload flag used internally by the installer so it does not run composer dump-autoload per-domain (the installer runs one consolidated dump after all ejects complete). This flag is not needed for normal manual sk:eject use.
2026-06-06 — v13.6.0
Minor release — Vendor-runtime migration completed + structured theme/layout/CSS system
13.6.0 bundles every change made since the last published release (v13.5.11) into a single version. It completes the "package runtime runs from vendor" migration on both the backend and the frontend, and reorganises the admin-panel layout and CSS into a structured, override-ready theme system. No visual change — the default build (VITE_SK_THEME=main) is byte-identical to v13.5.11. The sections below group the bundled changes by area.
Permission directive plugin resolves from vendor
The v-can / v-role Vue plugin (resources/js/plugins/permission.ts) is now served from the vendor package by default — the same resolution kit composables already use. A @/plugins/<name> import resolves to your local copy when one exists, otherwise it falls back to the vendor copy, so the kit can ship directive fixes without a stub re-copy. No behavior change: the directives are identical and app.ts still imports @/plugins/permission unchanged.
Changed
resources/js/plugins/permission.ts— moved into the vendor package. The dead, unuseduseCan()export was dropped (the live composable is@/composables/useCan); the file now ships onlyPermissionPlugin(thev-can/v-roledirectives), with no auto-import dependency.vite.config.ts— new@/plugins/*aliascustomResolver(resolvePlugin) mirroring@/composables/*: local-override-then-vendor fallback, ordered before the bare@alias.tsconfig.json— new@/plugins/*path mapping (local + vendor).
Added
sk:publish --tag=plugins— publish a local, editable copy of the Vue plugins to customise the permission directives.
Migration
No action required — resolution is automatic. Your existing resources/js/plugins/permission.ts keeps shadowing the vendor copy; delete it to adopt the vendor version. See UPGRADE.md.
All CSS cascade layers are now override slots
Every CSS layer in the theme system is now an overridable slot. Previously fonts.css, _base.scss, _auth.scss, and utilities.css were fixed imports outside the resolver; they now live under themes/main/ and are emitted by scripts/sk-theme-build.mjs in the correct cascade order alongside tokens, layout/*, and components/*. No visual change — the default build with VITE_SK_THEME=main is byte-identical to v13.5.11. The only difference is that a custom theme can now override any layer, including fonts, base reset, auth styles, and utility overrides, by placing a matching file under themes/custom/.
Changed
themes/main/fonts.css,themes/main/_base.scss,themes/main/_auth.scss,themes/main/utilities.css— moved fromresources/css/theme/root intothemes/main/. Content unchanged.scripts/sk-theme-build.mjs— HEAD slots (tokens.css,fonts.css,_base.scss) and TAIL slots (_auth.scss,utilities.css) are now resolved throughresolveSlot()(same override-or-main fallback used bylayout/*andcomponents/*). The cascade order is preserved:tokens → fonts → _base → layout/* → components/* → _auth → utilities.theme/theme.css— now contains only@import './_active.css'; the former fixed_auth.scssimport is gone.app.css— the former fixedutilities.csstail import is gone;utilities.cssis now the last slot emitted by the resolver.themes/custom/README.md— updated to list all overridable slots includingfonts.css,_base.scss,_auth.scss, andutilities.css.
Migration
No action required. sk:update delivers the updated files. The default build is byte-identical. To override a previously fixed layer, place the matching file under themes/custom/ (e.g. themes/custom/fonts.css). See docs/theme.md — Complete slot reference.
AppShell layout composition + build-time theme-override system (themes/main / themes/custom)
The admin-panel layout and CSS are reorganised into a structured, override-ready system. No visual change — the default build is byte-identical to the previous published version. The layout shell is split into a reusable AppShell.vue (structural backbone, sidebar state, named regions) and a thin AdminLayout.vue composition that wires in the standard admin components. The CSS monolith (_admin.scss + scattered _*.scss partials) is dissolved into a themes/main/ directory tree of individual slot files. A new opt-in themes/custom/ directory and scripts/sk-theme-build.mjs theme resolver enable per-slot overrides at build time: set VITE_SK_THEME=custom, place a file in themes/custom/components/datatable.css, and only that slot is replaced — everything else falls back to main. See docs/theme.md for the full reference and custom-override recipe.
Added
AppShell.vue(resources/js/layouts/AppShell.vue) — reusable structural layout shell. Owns the.admin-layout/.admin-main/.admin-contentskeleton anduseSidebarstate (single owner). Exposes five named slots:#sidebar(scoped:collapsed,mobileOpen,isMobile,closeMobile),#header(scoped:collapsed,isMobile,toggle),default,#footer,#overlays.themes/main/CSS tree —tokens.css(CSS custom properties, light + dark),layout/{shell,sidebar,header,page-header,footer}.css,components/{card,confirm,datatable,dialog,editor,formbuilder,menus,navigation,primevue,tabs,tag,toast}.css. Values are byte-identical to the removed partials.themes/custom/skeleton — empty override-theme directory with aREADME.mdexplaining the full-replacement + fallback model.scripts/sk-theme-build.mjs— theme resolver. ReadsVITE_SK_THEME(defaultmain); walksthemes/main/for the canonical slot list; for each slot emitsthemes/<active>/<slot>if it exists, elsethemes/main/<slot>; writestheme/_active.css. Override slots are annotated/* override */in the output. Invoked as an explicit&&step indevandbuild— not via npm lifecycle hooks — so it works correctly underignore-scripts=true.npm run theme:build— standalone script alias for the resolver (also runs as an explicit step indevandbuild).VITE_SK_THEME=mainadded to.env.examplewith inline documentation.- PrimeVue preset resolver —
scripts/vite-plugin-sk-theme.mjsnow intercepts the@/theme/presetimport at build time and resolves it toresources/js/theme/themes/<active>/preset.tswhen that file exists, otherwise falls back to the baseresources/js/theme/preset.ts. The base file stays in place — no consumer migration required. Theresources/js/theme/themes/custom/skeleton ships empty so the default build is byte-identical to the previous version. docs/theme.md+docs/theme.tr.md— updated: two-layer overview table (CSS override vs PrimeVue preset), PrimeVue preset layer section with directory layout, custom-palette recipe, and dependency-chain note (tokens.cssreads--p-*variables).
Changed
AdminLayout.vuerefactored into a thinAppShellcomposition. External prop/slot contract (title,subtitle,backUrl,default,page-actions) is unchanged — all existing pages continue to work without modification.theme.cssnow imports a single_active.cssinstead of an explicit list of partials. Import order is preserved._base.scssretains only base/reset rules; the:root/.darkCSS custom-property blocks moved tothemes/main/tokens.css.
Removed
_admin.scss— replaced bythemes/main/layout/*._datatable.scss,_formbuilder.scss,_dialog.scss,_toast.scss,_tag.scss,_card.scss,_editor.scss,_tabs.scss,_menus.scss,_navigation.scss,_confirm.scss,_primevue.scss— replaced bythemes/main/components/*.
Fixed
- Theme resolver now works under
ignore-scripts=true— the resolver is chained directly into thedevandbuildscripts (node scripts/sk-theme-build.mjs && vite …). Previously it ran aspredev/prebuildlifecycle hooks, which npm silently skips whenignore-scripts=trueis set (common in consumer projects and CI), causing_active.cssto be absent and the build to hard-fail. Thepredevandprebuildentries have been removed.
Migration
sk:update delivers all new stubs. No migration required if no moved file was customised — npm run build produces a byte-identical panel. If you customised a moved file, copy your changes into the corresponding themes/main/ slot or use themes/custom/ for an isolated override. See docs/UPGRADE.md (v13.5.11 → v13.6.0) for details.
Kit composables run from vendor; local-first resolver; sk:publish --tag=composables
v13.5.12 moves 15 kit composables out of the stub scaffold and into the vendor library. They now run directly from vendor/lvntr/laravel-starter-kit/resources/js/composables/ and are updated with every composer update. Import paths are fully unchanged — @/composables/<name> resolves local-first (consumer file wins if it exists) then falls back to the vendor copy, so no consumer import statement needs to change. useAdminMenu and index.ts remain as editable stubs because they depend on the consumer's generated routes and project-specific menu definition. TurnstileWidget.vue was likewise moved to the vendor library (@lvntr/components/ui/TurnstileWidget.vue).
Added
- 15 composables in vendor —
useApi,useCan,useConfirm,useDarkMode,useDatatableSelection,useDefinition,useDialog,useFileShare,useFlash,useImageLightbox,useMenuBuilder,usePageLoading,useRefreshBus,useSidebar,useUrlTabshipped inside the package. Updated viacomposer update— no manual file management. sk:publish --tag=composables— copies vendor composables intoresources/js/composables/for project-level customization. The local-first resolver picks up the local copy automatically; no alias or build-config changes required.TurnstileWidget.vuemoved to vendor — now available at@lvntr/components/ui/TurnstileWidget.vue.
Removed
- 15 composable stubs — removed from the scaffold. Existing projects are unaffected (local-first resolver keeps using local copies). To opt into vendor-managed upgrades: delete unmodified composable files from
resources/js/composables/, keepinguseAdminMenu.ts,index.ts, and any file you have customized.
Backend runtime classes & third-party configs run from vendor
The same release continues the v13.5.0 "runtime runs from vendor" migration on the backend. A set of helper classes, validation rules, and middleware moved out of the published scaffold into the vendor package, and three third-party config files are no longer copied into your app. Existing apps are not affected — App\… imports keep resolving (via class_alias for pure-moved classes, or a thin App\ shim for the rest), and a config you already published keeps winning. The only required step is composer update. See docs/UPGRADE.md (v13.5.11 → v13.6.0) for the full migration guide.
Added
- Vendor-resident backend classes —
HtmlSanitizer,TranslatableQueryHelpers,MediaPathGenerator,Scramble\ApiResponseExtension, and theAssignTraceId/SetLocale/ValidateTurnstilemiddleware now run fromLvntr\StarterKit\*. No stub is copied to the app; oldApp\…imports resolve viaclass_alias.ApiResponseExtensionis now properly registered with Scramble. - Vendor classes with a thin
App\shim —DatatableQueryBuilder,HttpsOrLocalhostUrl, andTurnstileRulekeep theirApp\…import path while running from vendor. HasTranslatableRulestrait → vendor (direct import) — nowLvntr\StarterKit\Support\HasTranslatableRules. Traits cannot be aliased, so import it from the vendor namespace (same convention asHasActivityLogging/HasMediaCollections).
Changed
- Third-party config overrides at runtime —
config/activitylog.php,config/inertia.php, andconfig/media-library.phpare no longer published.StarterKitServiceProvider::applyVendorConfigDefaults()applies only the kit's required keys (media-librarypath_generator+media_model, activityloginclude_soft_deleted_subjects, inertiassr.enabled) at runtime and skips any config you have published. The installer no longer AST-injects the media-library path generator.
Removed
- Backend scaffold stubs —
app/Support/{HtmlSanitizer,TranslatableQueryHelpers,MediaPathGenerator,HasTranslatableRules}.php,app/Support/Scramble/ApiResponseExtension.php,app/Http/Middleware/{AssignTraceId,SetLocale,ValidateTurnstile}.php, andconfig/{activitylog,inertia,media-library}.phpremoved from the scaffold. Upgraded apps keep existing copies (informational notice fromsk:update, never auto-deleted). For theHasTranslatableRulestrait, switchuseimports to the vendor namespace before deleting a local copy.
2026-06-04 — v13.5.11
Patch release — Standalone 3-skill set replaces monolithic bundled skill
v13.5.11 removes the previous 723-line monolithic skill (stubs/.claude/skills/lvntr-starter-kit/SKILL.md) and replaces it with three focused, self-contained skills distributed under stubs/.claude/skills/. The new skills require no additional tooling and cover the three main concerns of a starter-kit project: core rules, backend/DDD conventions, and frontend builder patterns.
sk:install gains a --without-ai-skill flag for projects that prefer not to publish any AI skill files.
Added
stubs/.claude/skills/lvntr-starter-kit/— core skill: hard rules, recipe pointers, permissions/i18n config, cross-domainreferences/links.stubs/.claude/skills/lvntr-kit-domain/— backend / DDD skill: Actions, Services, FormRequest, Resource, Repository conventions, and domain boundary guidance.stubs/.claude/skills/lvntr-kit-frontend/— frontend skill: FormBuilder / DatatableBuilder / TabBuilder patterns, composables (useApi,useDialog,useForm), and starter-kit component rules.sk:install --without-ai-skill— opt-out flag; skips publishing the skill files to the host application.
Removed
stubs/.claude/skills/lvntr-starter-kit/SKILL.md— the 723-line monolithic skill has been removed. If you published the old file to your host application, delete.claude/skills/lvntr-starter-kit/SKILL.mdbefore re-runningvendor:publish.
2026-05-30 — v13.5.10
Patch release — SkCard primitive, card title actions slot, caption divider, SkForm grid span + 12-column support, and SkColorSelector neutral palette
v13.5.10 introduces SkCard, a shared wrapper around PrimeVue Card that provides a single source of truth for the kit's card surfaces. The same patch adds two consumer-facing slots powered by it: #title-end on SkForm's root card and per-section #section-${key}-title-end on every FB.section() card. Both render to the right of the title in the same row, ready to host action buttons, status badges, or contextual indicators. The section slot is scoped — it exposes { values } (a reactive snapshot of the current form values) so consumers can render conditionally. SkCard itself accepts title, subtitle, transparent, divider, and pt props plus header/title/subtitle/content/footer/title-end slots; class fallthrough works via inheritAttrs: false + useAttrs because PrimeVue Card opts out of attribute inheritance on its own root. SkForm.vue and SkFormFieldRenderer.vue were refactored to use SkCard instead of <Card> directly — their cardPt/transparentCard/sectionCardPt helpers are gone, the title flex wrapper and caption bottom-divider styles moved out of _formbuilder.scss into _card.scss (.sk-card--divider .p-card-caption), and now any consumer that wraps content in SkCard gets the same caption header behavior (title text on the left, #title-end on the right, subtitle below, divider underneath the caption block).
The same release also adds field-level grid span control to SkForm: BaseFieldConfig.colSpan lets any field (or a field inside a section) declare how many columns it occupies in the form grid, so layouts like "full-width title + two fields side by side" are now possible, and .cols() supports the full 1–12 range instead of falling back above 6. SkColorSelector gains the 5 neutral Tailwind families (slate, gray, zinc, neutral, stone), bringing the total palette to 22 families.
Added
BaseFieldConfig.colSpan?: number— sets how many columns a field (or a field inside a section) spans in the form grid (1..cols). Omitted → existing behavior (1 cell). Values exceedingcolsare clamped automatically; inside a section the clamp usessectionCols(the section's owncols, or the formcols).BaseFieldBuilder.colSpan(n: number)— chainable.colSpan(n)added to every field builder. Example:FB.inputText().key('title').label('Title').colSpan(12).SkColorSelector— 5 neutral Tailwind families —slate,gray,zinc,neutral,stoneadded with all 50–950 shades (official Tailwind v4 hex). Total palette: 22 families.SkCardUI primitive —resources/js/components/Lvntr-Starter-Kit/ui/SkCard.vue. Shared wrapper around PrimeVue Card used bySkForm(and intended for futureSkDatatable/ page-level cards) so caption behavior, the#title-endslot, and the bottom divider have a single implementation.- Props:
title?: string,subtitle?: string,transparent?: boolean(defaultfalse—trueremoves background/shadow/padding, useful inside dialogs or nested cards),divider?: boolean(defaulttrue— draws a bottom border under the caption block),pt?: Record<string, any>(merged into the PrimeVue Card pt; consumer keys win on conflicts). - Slots:
header,title,subtitle,content(the default slot also maps to content),footer,title-end(right-aligned action/badge/status slot). inheritAttrs: false+useAttrsso outerclassfallthrough still reaches the Card root (PrimeVue Card setsinheritAttrs: falseon its own root, which otherwise blocks class propagation).- Exported from
index.tsasSkCard.
- Props:
SkForm.vue—#title-endslot — new slot rendered to the right of the form-level card title. Use it for action buttons, badges, or status indicators that should live in the same row as the heading. The slot is only rendered when content is provided.SkFormFieldRenderer.vue— per-section#section-${key}-title-endslot — scoped slot rendered to the right of each section card title. BecauseSkForm.vuealready forwards every slot via the genericv-for $slotspattern, consumers use it directly on<SkForm>as<template #section-address-title-end="{ values }">. Slot scope:{ values }— a reactive snapshot of the current form values, useful for conditional rendering.- Docs — new "SkCard" section in
docs/ui-components.mdanddocs/ui-components.tr.md; new "Card Title Actions Slot" / "Card Başlık Sağ Slot" section indocs/formbuilder.mdanddocs/formbuilder.tr.md.
Changed
SkForm.vue— root<Card>→<SkCard>refactor — the internalcardPtcomputed andtransparentCardstyle constant were removed;:transparent="isTransparentCard"is passed toSkCardinstead. The form card's title and subtitle are now passed via:titleand:subtitleprops; the flex title wrapper and caption bottom-divider are produced once insideSkCard.SkFormFieldRenderer.vue— section render switched to<SkCard>—sectionCardPtreplaced with asectionIsTransparenthelper +:transparentprop. The section title flex wrapper andtitle-endslot are now delegated toSkCard; the icon-bearing title (SkIcon+ text) is rendered directly insideSkCard's#titleslot.RenderCtx(SkFormFieldRenderer.vue) —transparentCardfield removed —SkCard'stransparentprop is the only source of truth; the floating style constant in the context object is no longer needed.stubs/resources/css/theme/_card.scss— addedSkCardstyles:.sk-card__title-row(flex w-full justify-between, title row).sk-card__title-text(title text, inline-flex for icon alignment).sk-card__title-end(right slot container, shrink-0).sk-card--divider .p-card-caption(pb-3 mb-1 border-bunder the caption block +--p-surface-200/--p-surface-700dark variant). Only triggers insideSkCard— other PrimeVue Card usages stay untouched.
stubs/resources/css/theme/_formbuilder.scss— the transitional selectors that this work originally introduced (.sk-fb__card*,.sk-fb__section-title-wrapper,.sk-fb__section-title-end,.sk-fb__card .p-card-caption,.sk-fb__section .p-card-caption) were removed. A short note now points to_card.scss.SkForm.vue—colsClassMapextended to 1–12 — values 7–12 previously fell back to the default grid;cols(7)–cols(12)now applymd:grid-cols-Ndirectly.SkForm.vue+SkFormFieldRenderer.vue—colSpanClassMap— purge-safe static map added; top-level and in-section field wrappers receivemd:col-span-Nbased oncolSpan. Fields withoutcolSpanrender identically to before (no regression).
2026-05-21 — v13.5.9
Patch release — SkIcon primitive, section/card grouping, and icon APIs
v13.5.9 introduces SkIcon, a package-agnostic icon renderer that auto-detects three formats from a single icon: string prop: raw SVG (v-html), image URL (<img>), or class-based icon (<i :class> — works with PrimeIcons, FontAwesome, MDI, Lucide, Iconify, and any other CSS icon library). A unified icon API lands across all field types via BaseFieldConfig: labelIcon / labelIconPosition place an icon beside the label in any layout, while icon / iconPosition place one inside the input (supported on input-text, input-number, input-mask, and password without feedback). Title fields gain their own icon / iconPosition pair. The headline feature is SectionFieldConfig (type: 'section') and its FB.section() fluent builder — fields can now be visually grouped inside a PrimeVue Card with a title, subtitle, icon, and configurable column count, while the form payload stays flat (section keys are never emitted). SkForm.vue gained a flatFields computed backed by an iterateAllFields generator so sections are transparent to all existing field-processing logic (file upload keys, date transforms, definition preloads, dynamic selects). SkFormFieldRenderer.vue was extracted to handle recursive rendering and slot forwarding. InputTextFieldConfig.icon / iconPosition are now deprecated in favour of the base-level API.
Added
SkIconUI primitive — package-agnostic icon renderer. Auto-detects from a singleicon: stringprop:<svg…→ raw SVG (v-html),^(https?:|data:)→<img>, otherwise →<i :class>(PrimeIcons, FontAwesome, MDI, Lucide, Iconify and any class-based icon set). Security:iconmust only be passed from builder config (developer-controlled) — user-sourced strings are an XSS risk (the<svg…path usesv-html).BaseFieldConfigicon fields — shared icon API for all field types:labelIcon?: string+labelIconPosition?: 'left' | 'right'(default:'left') — icon beside the label in all layout paths.icon?: string+iconPosition?: 'left' | 'right'(default:'left') — icon inside the input. Supported types:input-text,input-number,input-mask,password(custom path — no icon whenfeedback: true).groupPrefix/groupSuffixtake precedence — input icon is disabled when they are present.
TitleFieldConfigicon fields —icon?: string+iconPosition?: 'left' | 'right'. Example:FB.title('General').icon('pi pi-info-circle').SectionFieldConfig(new field typetype: 'section') — visual field grouping inside a Card:title?(translation key, falls back tolabel),subtitle?,icon?,iconPosition?cols?: number(default: parent form'scols)fields: FieldConfig[](nested — one level only; nested sections are not supported)isCard?: boolean(default: card visible;false→ transparent Card)- Form payload stays flat — the section's
keyis never emitted; sections are a purely visual grouping primitive.
SectionBuilderandFB.section(title?)factory — fluent API:.title(t),.subtitle(s),.icon(str),.iconPosition(p),.cols(c),.isCard(enabled),.addFields(...).BaseFieldBuilderfluent methods —.labelIcon(str),.labelIconPosition(p),.icon(str),.iconPosition(p)now available on all field builders (moved fromInputTextBuilderto base — same signature, no behaviour change).TitleBuilder.icon()and.iconPosition()methods.SkFormFieldRenderer.vue— extracted recursive field renderer. Section render, slot forwarding, and label/title icon rendering are now handled here;SkForm.vue's template is simplified.- Docs — 5 new sections in
docs/formbuilder.mdanddocs/formbuilder.tr.md: Icons (Package-Agnostic), Label Icons, Input Icons, Title Icons, Section / Card Grouping. XSS security note in both languages.
Changed
AppDialog.vue—confirmSeverityno longer defaults to'primary'—state.footer?.severity ?? 'primary'→state.footer?.severity. The confirm button now falls back to PrimeVue Button's own default appearance (from the theme preset). Existing dialogs that did not explicitly setDialogFooter.severitymay see a visual change.useDialog.ts—DialogFooterSeveritytype widened —'primary'removed (not a valid PrimeVue Button severity);'info','help','contrast'added. Full list:'secondary' | 'success' | 'info' | 'warn' | 'help' | 'danger' | 'contrast'.SkForm.vue— flat field iteration —derivedDefaults,currentValues,definitionKeys,dynamicSelectFields,hasFileFields,dateOnlyFieldscomputeds are now backed by the newflatFieldscomputed (iterativeiterateAllFieldsgenerator). Fields inside sections are automatically categorised correctly (file-upload existingMediaKey resolve, date-picker toLocalDateStr transform, definition preload, dynamic optionsUrl fetch). Forms without sections render identically to before (no regression).SkFormInput.vue— generic input icon — theIconFieldwrapping pattern previously only forinput-textis now active forinput-number,input-mask, andpassword(custom path). Icon descriptors render viaSkIcon, so MDI / FA / Lucide / Iconify / SVG / img URL work in addition to PrimeIcons.BaseFieldConfig.icontakes precedence;InputTextFieldConfig.iconis kept as a legacy fallback.stubs/resources/css/theme/_formbuilder.scss— minimalinline-flex items-center gapadded to.sk-fb__titleand.sk-fb__labelfor icon alignment (line-height and padding unchanged). New sections:SKICON & LABEL/TITLE ICONS(.sk-icon,.sk-icon--svg svg,.sk-icon--img,.sk-fb__label-icon,.sk-fb__title-icon,.sk-fb__section-icon+--left/--rightmodifiers),SECTION CARD(.sk-fb__section,.sk-fb__section-title,.sk-fb__section-field).
Deprecated
InputTextFieldConfig.iconandInputTextFieldConfig.iconPosition— use the newBaseFieldConfig.iconandBaseFieldConfig.iconPositioninstead. Legacy fields are kept for backward compatibility (SkFormInput.vueusesbase ?? legacyfallback and produces the same render); they will be removed in the next major version.
Upgrade
composer update lvntr/laravel-starter-kit
# Re-publish affected stubs (warning: customised stubs are overridden — diff first)
# stubs/resources/css/theme/_formbuilder.scss
# stubs/resources/js/composables/useDialog.ts ← DialogFooterSeverity type changed
php artisan vendor:publish --tag=starter-kit-stubs --force
DialogFooterSeverity breaking change: 'primary' is no longer a valid value. If you used severity: 'primary' in any useDialog().open(...) call, remove it (the Button will apply its own theme default) or replace it with a valid value such as 'secondary' or 'contrast'. TypeScript will already flag these as errors.
Migration: legacy InputTextFieldConfig.icon calls continue to work (deprecated, kept until removal). To use the new features:
// Label icon — any field type
FB.inputText().key('email').label('Email').labelIcon('pi pi-envelope')
// Input icon — input-text/number/mask/password
FB.inputText().key('search').icon('pi pi-search') // PrimeIcons
FB.inputText().key('user').icon('mdi mdi-account') // Material Design Icons
FB.inputText().key('star').icon('fa fa-star').iconPosition('right') // FontAwesome
FB.inputText().key('logo').icon('https://cdn.example.com/icon.svg') // URL
// Title icon
FB.title('General Info').icon('pi pi-info-circle')
// Section / Card grouping
FB.form()
.isCard(false)
.addFields(
FB.section('Personal Info').icon('pi pi-user').cols(2).addFields(
FB.inputText().key('first_name').label('First Name'),
FB.inputText().key('last_name').label('Last Name'),
),
FB.section('Address').icon('pi pi-map-marker').addFields(/* ... */),
)
.build();
2026-05-20 — v13.5.8
Patch release — AppDialog Material Flat shell, rich header & footer API, scrollbar-gap fix
AppDialog has been redesigned around PrimeVue Dialog's #container template into a self-contained "Material Flat" shell: gradient icon lozenge + title + subtitle in the header, an optional slate-100 sticky footer with hint icon/text on the left and Cancel/Confirm buttons on the right, a softer dual-layer drop shadow, and a custom "rise" enter/leave animation. The shell is fully scoped (sk-dlg PT class) so ConfirmDialog and other Dialog usages remain untouched. The useDialog composable gained subtitle, icon, and footer open options, a new DialogFooter interface, and setFooter() / patchFooter() methods so components rendered inside the dialog can mutate the footer (e.g. flip the confirm button to a loading state) without re-opening. The remaining sticky-bar issue from v13.5.7 — a ~10 px white gap on the right side of the gray footer when the form scrolled — is fixed by hiding the dialog body's scrollbar visually; scroll still works via wheel / trackpad / keyboard, so the slate-100 bar now reaches the dialog's right edge cleanly.
Added
AppDialogMaterial Flat shell — header now ships an icon lozenge (state.icon), title (state.header), and subtitle (state.subtitle); a slate-themed close button replaces PrimeVue's default. Optional opt-in footer renders a sticky slate-100 action bar with hint icon/text + Cancel/Confirm.useDialogrich-header & footer API —OpenOptions.subtitle,OpenOptions.icon,OpenOptions.footeradded. NewDialogFootertype withicon,text,cancelLabel,confirmLabel,confirmIcon,severity,onConfirm,hideCancel,disabled,loading. NewsetFooter()andpatchFooter()methods._dialog.scss— new stylesheet imported fromtheme.css; defines the shell (mask, root, head/lead/title-block, body, foot/info/actions) and is scoped via thesk-dlgPT class.
Changed
preset.tsmodal token —borderRadius.xl→borderRadius.md(6 px),padding: 1.25rem→padding: 0(shell-level padding handled insideAppDialog), drop shadow updated to a softer dual-layer (0 24px 60px -20px ...,0 6px 20px -6px ...).
Fixed
- Form scrollbar gap on the right of the footer — long forms inside
AppDialogleft a ~10 px white gap between the right edge of the slate-100 action bar and the dialog's right edge (the body's scrollbar consumed content width and the bar's-mx-8extension only reached the body's content edge)..sk-dlg__body:has(.sk-fb--dialog)now hides the scrollbar visually (scrollbar-width: none+::-webkit-scrollbar { width: 0 }); scroll continues to work via wheel / trackpad / arrow keys / Page Up–Down / Home–End.
Upgrade
composer update lvntr/laravel-starter-kit
# Re-publish affected stubs (warning: customised stubs are overridden — diff first)
# stubs/resources/css/theme/{_dialog.scss,_formbuilder.scss,theme.css}
# stubs/resources/js/composables/useDialog.ts
# stubs/resources/js/theme/preset.ts
php artisan vendor:publish --tag=starter-kit-stubs --force
Behavioural note: the Dialog body's scrollbar is intentionally invisible inside form dialogs. The visible track is hidden so the slate-100 action bar reaches the dialog edge with no gap; scroll continues to work via wheel, trackpad, arrow keys, Page Up/Down, Home/End.
2026-05-19 — v13.5.7
Patch release — Dialog sticky bar bleed fix, AvatarUpload redesign, 14px root typography
AppDialog's sticky form action bar (Cancel / Update) was leaking scrolling content out from under the buttons whenever a form exceeded the Dialog's visible height — the root cause was Dialog content's default padding: 1.25rem leaving a transparent gap underneath the sticky bar. Fixed by zeroing the Dialog's padding-bottom via the PT API, having SkForm advertise dialog mode through a sk-fb--dialog marker class, and extending the sticky bar edge-to-edge with a matching rounded-b-xl so the bar mirrors the Dialog's rounded corners. AvatarUpload has been redesigned from the previous stacked card to a single-row layout (avatar · title/hint · actions) with a smaller 56px avatar, primary border accent, and a new initials prop for showing user initials when no photo is uploaded. The title and subtitle props now have explicit three-state semantics: omit → default i18n, non-empty string → that text, empty string → row hidden entirely. Typography has been rebased to a 14px root system written in rem (so user browser font-size preferences and a11y zoom continue to scale proportionally); the previous absolute-px override is gone. Profile vertical tabs gained description text and per-tab icon colors.
Added
- Profile tabs —
Profile/Index.vuenow declaresdescription()andiconColor()on each tab;sk-profile.tab_descriptions.{general,password,security,sessions}keys added (TR/EN). AvatarUpload :initials— renders user initials in the avatar slot when noavatarUrlis provided; falls back topi-userotherwise.
Changed
AvatarUploadrow layout — avatar shrunk tosize-14, primary-200 border on primary-50 background, "Remove" isseverity-secondary text, "Change" isoutlined. Title and hint render inline; the avatar block can be rendered without any caption by passing:title=""and/or:subtitle="".AvatarUploadtitle/subtitlesemantics —undefined→ default i18n key, non-empty string → that text,''→ element fully hidden viav-if. Restores the ability to opt out of the labels.sk-avatar.hint— copy reformatted to"JPG · PNG · GIF — max 2 MB · 512×512 recommended"(TR:"JPG · PNG · GIF — en fazla 2 MB · 512×512 önerilir").- Typography (14px root, rem) —
_base.scsssetshtml { font-size: 0.875rem };utilities.cssdeclares all--text-*tokens in rem relative to that root (--text-base: 1rem,--text-xs: 0.857rem, etc). The transient absolute-px override from a previous WIP is replaced; a11y zoom now scales the whole UI again. - FileManager text rebalance — favourites/trash empty-state titles/subtitles and file-type filter pills downgraded from
text-lgtotext-base.sk-user-menu__itemraised fromtext-smtotext-base.
Fixed
- Sticky action bar bleed (
AppDialog/SkForm) — long forms insideAppDialogwere leaking scrolling content from underneath the sticky bottom bar; DialogcontentPT now zerospadding-bottomand switches to a flex column layout,SkFormadds ansk-fb--dialogmarker, and_formbuilder.scsspaints.sk-fb__actionsopaque, edge-to-edge withrounded-b-xlmatchingborderRadius.xl. Scroll content can no longer slide behind the buttons.
Upgrade
composer update lvntr/laravel-starter-kit
# Re-publish affected stubs (warning: customised stubs are overridden — diff first)
# stubs/resources/css/theme/{_base.scss,utilities.css,_formbuilder.scss,_tabs.scss,_menus.scss}
# stubs/resources/js/pages/Profile/Index.vue
# stubs/resources/js/pages/Profile/components/ProfileInfoTab.vue
# stubs/lang/{tr,en}/{sk-avatar.php,sk-profile.php}
php artisan vendor:publish --tag=starter-kit-stubs --force
Behavioural note for AvatarUpload: if your code was passing :subtitle="" expecting the default hint to render anyway, it will now hide the hint row instead. Either remove the prop (default i18n applies) or supply a non-empty value.
2026-05-10 — v13.5.6
Patch release — axios removed from SystemHealthTab, API envelope compliance, FileManager type fix
SystemHealthController was using response()->json() instead of the required to_api() helper, producing a non-standard JSON body that useApi could not parse (no success envelope). Fixed. SystemHealthTab.vue was importing and calling axios directly, violating the SK hard rule that all API calls must go through the useApi composable. Replaced with useApi({ toast: false }). A TypeScript type error in FileManager.vue is also resolved: @click received a BusyState | null value; vue-tsc does not narrow busy through v-if in event handlers; double optional-chaining busy?.onCancel?.() resolves both null cases.
Fixed
SystemHealthController@run—response()->json()replaced withto_api([...], $message); return type updated toApiResponse|RedirectResponse. The raw JSON response bypassed the standard{ success, data, message }envelope expected byuseApi, causing the frontend to throw a parse error.SystemHealthTab.vue—import axios from 'axios'removed;useApi({ toast: false })composable added.axios.post<...>(url)replaced withapi.post<...>(url). Using axios directly violates the SK hard rule; all API calls must go throughuseApi.FileManager.vue—@click="busy.onCancel"changed to@click="() => busy?.onCancel?.()".busyisBusyState | nullandonCancelis(() => void) | null; vue-tsc does not narrow either throughv-ifin event handlers, so double optional-chaining is required.
Upgrade
composer update lvntr/laravel-starter-kit
# Re-publish affected stubs (warning: customised stubs are overridden — diff first)
# SystemHealthController.php, SystemHealthTab.vue
php artisan vendor:publish --tag=starter-kit-stubs --force
2026-05-08 — v13.5.5
Patch release — Passport setup fixes, API client scopes removed, Settings tabs, UUID fix
System Health and API client/token management are now embedded as Settings tabs — no more standalone admin pages. A critical bug is fixed: the scopes field on OAuth clients never existed in Passport's schema and caused a fatal SQL error on every client create/update. Passport setup now happens fully automatically during sk:install and site:install (personal access client creation was previously missing). A runtime guard ensures the api guard required by Passport is always present even on Laravel 11 where it was removed from the default auth.php. Datatable refresh after record creation is now immediate. UUID type fix for the file_manager_share_revocations migration, and InstallCommand reliability improvements round out the release.
Changed
- System Health moved to Settings tab. The
/admin/system-healthstandalone page is replaced by a Settings tab. TheuseAdminMenu.tssidebar entry andsystem-healthroute import are removed.SystemHealthTab.vueis now wrapped in a PrimeVueCardwith title, subtitle, and content slots; the refresh button is inlined in the#titleslot withsize="small". SystemHealthController@run— reverted back toback(). The earlierredirect()->route('admin.system-health.index')was introduced in v13.5.4 but does not make sense now that System Health lives inside the Settings page.ApiClientsManageTab.vue/ApiTokensManageTab.vue— custom<header>block and standaloneButtonimport replaced withisCard(true).cardTitle(...).cardSubtitle(...)on the table builder; the create action is registered viatableBuilder.create({ label, onClick })so theDatatableBuilderowns the full card layout.
Fixed
- API client
scopesfield removed. Thescopescolumn does not exist onoauth_clientsin native Passport. The field was dead code acrossStoreApiClientRequest,UpdateApiClientRequest,CreateApiClientAction,UpdateApiClientAction,ApiClientController,ApiClientResource,ApiClientForm.vue, andApiClientsManageTab.vue, and causedColumn not found: 1054 Unknown column 'scopes'on every create/update. PAT scopes ($user->createToken($name, $scopes)) are unaffected — they are stored onoauth_access_tokens.scopesand continue to work. passport:client --personalnow runs automatically during install. Bothsk:installandsite:installnow executepassport:client --personal --provider=usersimmediately afterpassport:keys. The missing step causedLogicException: Unable to determine authentication provideron token creation in fresh installs.- Laravel 11
apiguard auto-injected at runtime.StarterKitServiceProvider::configurePassport()now checks forauth.guards.apiand injects['driver' => 'passport', 'provider' => 'users']when absent. Laravel 11 removed this guard from the defaultauth.php; Passport'screateToken()requires it to resolve the user provider. - Datatable refreshes immediately after record creation.
ApiClientsManageTab.vueandApiTokensManageTab.vuenow callbus.refresh(REFRESH_KEY)as soon asonCreatedfires (i.e. the moment the API responds with success), instead of waiting for the user to click "I've saved it" inOneTimeSecretModal. file_manager_share_revocationsmigration —revoked_by_user_idcolumn changed fromunsignedBigIntegertouuidto match the UUID primary key on theuserstable. Upgrading from v13.5.3: see the migration note below.ShareRevocationmodel —$revoked_by_user_idPHPDoc type corrected fromint|nulltostring|null.InstallCommand—app/Helpers/custom.phpis now auto-created (minimal<?phpstub) when missing, beforecomposer dump-autoload. Absence of this file caused every subsequent artisan call to fail on fresh installs.DatabaseTestCase— in-memoryfile_manager_share_revocationsschema updated to useuuidforrevoked_by_user_id, matching the fixed migration.
UI
SkDatatable— inisCardmode thecaptionPT slot now receivespadding: var(--p-card-body-padding) var(--p-card-body-padding) 0, so title and subtitle align with the standard Card body; the table toolbar and content remain edge-to-edge.
Upgrade
composer update lvntr/laravel-starter-kit
# Re-publish affected stubs (warning: customised stubs are overridden — diff first)
# useAdminMenu.ts, SystemHealthController.php, SystemHealthTab.vue,
# ApiClientController.php, ApiTokenController.php, ApiClientForm.vue,
# ApiClientsManageTab.vue, ApiTokensManageTab.vue, CreateTokenModal.vue,
# OneTimeSecretModal.vue, api-client-route.php, api-token-route.php
php artisan vendor:publish --tag=starter-kit-stubs --force
Passport personal access client — if you never ran passport:client --personal in a previous install, run it once:
php artisan passport:client --personal --provider=users
Migration note — if you published file_manager_share_revocations in v13.5.3, run a new migration to fix the column type:
Schema::table('file_manager_share_revocations', function (Blueprint $table) {
$table->dropForeign(['revoked_by_user_id']);
$table->dropColumn('revoked_by_user_id');
$table->uuid('revoked_by_user_id')->nullable()->after('revoked_at');
$table->foreign('revoked_by_user_id')->references('id')->on('users')->nullOnDelete();
});
No new permissions or config keys in this release.
2026-05-07 — v13.5.4
Patch release — v13.5.3 follow-up: stub fixes, type alignments and CI pipeline reliability
This patch fixes a handful of stub regressions exposed after v13.5.3 (AdminHeader role typo, missing System Health menu item, SettingsDefaultsQuery payload missing storage_usage, SystemHealthController redirect target, and trans() count typing in the Logs pages). The TabBuilder gains a rose icon color so the new System Health tab compiles. The CI pipeline is re-ordered so auto-imports.d.ts and components.d.ts are generated before typecheck, with new guards in vite.config.ts for environments without PHP (Wayfinder) or running under Vitest (laravel-vite-plugin HMR check). No new permissions, migrations or config keys.
Added
- TabBuilder —
roseicon color.TabIconColoracceptsrose;_tabs.scssships matching--p-rose-*light/dark rules. Required by the System Health tab in Settings.
Fixed
AdminHeader.vue—page.props.auth?.role(singular, non-existent) corrected toroles?.[0], matching theroles: string[]shared page-prop shape.useAdminMenu.ts— added missingimport systemHealth from '@/routes/system-health'and a System Health entry (permission: 'system.health.view'). The v13.5.3 page was reachable only by URL.SettingsDefaultsQuery.php— addedstorage_usage(used_bytes,quota_bytes) payload via theResolvesMediaModeltrait (computeStorageUsed()/storageQuotaBytes()). Drives theStorageQuotaCardshipped in v13.5.2.SystemHealthController@run— switched fromback()toredirect()->route('admin.system-health.index')(POST → safe GET).Admin/Logs/{Index,Show}.vue—trans()/$t()countreplacement values wrapped inString(...);laravel-vue-i18nv2.8 strict types reject raw numbers.tsconfig.json— added@lvntr/components/*path mapping aligned with the Vite alias so@lvntr/components/FormBuilder/coreand friends resolve undervue-tsc.env.d.ts— typed globalwindow.turnstile, plus a@/routes/*wildcard module declaration as a fallback when wayfinder hasn't run yet.
Build / CI
vite.config.ts—isWayfinderAvailable()skips the wayfinder plugin when there is noartisan(CI / package repo), and anisVitestguard skipslaravel-vite-plugin+inertia()duringvitest run(no more "Vite HMR server in CI" startup error).- GitHub Actions Node job re-ordered —
npm ci→ vendor symlink → route stub generation → build → typecheck → lint (continue-on-error) → test. Build now generatesauto-imports.d.ts/components.d.tsbefore vue-tsc runs. scripts/ci/generate-route-stubs.mjs— node-only CI fallback that writes 16 minimal@/routes/*stub files; gitignored so host apps still let wayfinder generate the real ones.- Doctor tests updated to expect the (intentional) English check messages.
.gitignore— wayfinder routes, the CI vendor symlink, and Vite build artefacts (stubs/public/build/,stubs/bootstrap/ssr/) are now ignored.
Upgrade
composer update lvntr/laravel-starter-kit
# Re-publish affected stubs (warning: customised stubs are overridden — diff first)
# AdminHeader.vue, useAdminMenu.ts, SystemHealthController.php, SettingsDefaultsQuery.php,
# Logs/{Index,Show}.vue, env.d.ts, tsconfig.json, vite.config.ts
php artisan vendor:publish --tag=starter-kit-stubs --force
# Re-publish theme files for the new rose tab color
php artisan vendor:publish --tag=starter-kit-theme --force
npm run build
If you maintain a custom tsconfig.json, add the @lvntr/components/* mapping (must come before @lvntr/*):
"paths": {
"@/*": ["resources/js/*"],
"@lvntr/components/*": [
"vendor/lvntr/laravel-starter-kit/resources/js/components/Lvntr-Starter-Kit/*"
],
"@lvntr/*": ["vendor/lvntr/laravel-starter-kit/resources/js/*"]
}
No new permissions, migrations or config keys.
2026-05-06 — v13.5.3
Release — sk:doctor, System Health, Signed Share Link, Bulk Action API, API Client Admin UI, security updates and bug fixes
This release adds the sk:doctor health-check command and its System Health admin page, HMAC-signed file share links, a cross-page Bulk Action API for the DatatableBuilder, the Domain Generator v2 opt-in flags, and a full Passport API Client & Token admin UI. It also includes security dependency bumps, event-dispatch fixes for nested folder deletes, Inertia flash response fixes for bulk controllers, and UUID/ULID bulk-action ID support. Existing apps should run the upgrade steps below.
Added
sk:doctorartisan command — system health check covering 12 control points: PHP extensions, database connection, Redis, Passport keys, storage symlink, writable directories, queue driver, schedule run, mail driver, npm build artifacts, config cache, FileManager disk connection. Machine-readable output via--json; selective checks via--only=database,redis,.... Exit codes:0OK,1WARN,2FAIL.- Admin Panel — System Health page (
/admin/system-health) — visualisessk:doctoroutput with per-check status badges and a manual refresh button. Access permission:system.health.view. - File Manager — Signed Share Link — HMAC-signed public access URLs.
POST /file-manager/sharecreates a link with a TTL;POST /file-manager/share/revokerevokes it;GET /file-manager/share/{media}?expires&signaturevalidates. Config keys:file-manager.share.enabled,default_ttl_hours(default 24),max_ttl_hours(default 720),allow_revoke. Revocations tracked infile_manager_share_revocationswith a(media_id, signed_token_hash)composite unique index. New permissions:share-media,revoke-share-media. - DatatableBuilder — Bulk Action API —
BulkActioninterface andBulkActionDispatcherfor cross-page bulk operations.SkDatatablesupportsselect_all_filteredmode (with filter snapshot) and cross-page selection. Request payload:{action, ids, select_all_filtered, filter_snapshot}; response:{processed, skipped, failed, message}. Shipped stubs:BulkDeleteUserAction(rank-aware) andBulkDeleteRoleAction(guards against system roles). - Domain Generator v2 (
make:sk-domain) — opt-in flags —--with-policy,--with-factory,--with-seeder,--with-test,--with-relationsindividually or combined as--with=policy,factory,test.--relations="belongsTo:User,hasMany:Comment,morphTo:commentable"generates relationship scaffolding automatically. Flag-free invocation preserves v13.5.x behaviour (backward compatible). - API Client & Token Admin UI — admin interface for Passport authorization_code and client_credentials grants and Personal Access Tokens (
/admin/api-clients,/admin/api-tokens). Client secrets and PATs are shown in plaintext only once on creation (Cache-Control: no-store);OneTimeSecretModalcannot be dismissed. New permissions:api-clients.create,api-clients.read,api-clients.update,api-clients.delete,api-tokens.create,api-tokens.read,api-tokens.delete. New validation rule:HttpsOrLocalhostUrl(RFC 8252 §8.3 — HTTPS only, localhost HTTP exception). - CI Workflow (GitHub Actions) — PHP test (
pest), lint (pint), and Node 22 build/typecheck/lint jobs. Concurrent runs on the same branch/PR are cancelled viaconcurrency: cancel-in-progress. composer test(vendor/bin/pest tests/Feature) andcomposer lint(vendor/bin/pint --test) scripts added for contributors.
Fixed
DeleteFolderAction— descendant folders were permanently deleted via a query-builderforceDelete()call, which skipped Eloquent model events. TheforceDeletedobserver inFileFolder(responsible for cleaning upfile_favorites) never fired for sub-folders, leaving orphan favorite records. Changed to model-level iteration so everyforceDeletedevent is dispatched correctly.sk:update—node_modules/filtered from stubs scan.node_modules/added toNEVER_UPDATE_PATHS;isNeverUpdate()check applied to all loops inupdateModifiableFiles,addNewFiles,migrateHashRegistryandupdateHashRegistry. In symlinked (path-repository) setups,stubs/node_modules/was leaking into the candidate file list.sk:doctorandsk:updateconsole output translated to English. All user-facing messages, tips and table headers inDoctorCommand,UpdateCommandand the 12DoctorCheckclasses are now in English; PHP code comments are unchanged.- Bulk action controllers — Inertia flash response.
UserBulkControllerandRoleBulkControllernow returnback()->with('success'/'error', ...)instead ofApiResponse(JSON). The previous JSON response was breaking Inertia'sonSuccess/onErrorflow and rendering raw JSON on screen; success/error messages now reachSkFlash/useFlashviaHandleInertiaRequestsflash sharing. - Bulk action validation — UUID/ULID/integer ID support.
BulkActionRequest::rules()updated:ids.*rule changed fromintegertostring|min:1|max:64;prepareForValidation()casts all incoming IDs to string. The previousintegerrule caused "The ids.0 field must be an integer" for models usingHasUuids(User, FileBucket, FileFolder, etc.). The new rule supports integer auto-increment, UUID (36 chars) and ULID (26 chars) primary keys in a single payload schema.
Security
dedoc/scramblebumped from^0.13to^0.13.22to address a reported RCE-class advisory (GHSA fixed in v0.13.22).phpseclib/phpseclibupdated from3.0.51to3.0.52to address a high-severity DoS advisory (transitive vialaravel/passport).- Signed Share Link — cross-media token hijack protection.
(media_id, signed_token_hash)composite unique index prevents a token from being valid for a different media record. - Personal Access Token — privilege escalation guard.
user_idbody field is rejected; tokens are always minted for the authenticated user. - Passport client
confidentialenforcement. Onlyconfidential=trueclients can be created via the API Client UI; authorization_code grant requires min:1 redirect URIs with HTTPS. Existing DB records are unaffected.
Changed
StarterKitServiceProvider— Passport scope andGate::beforeregistrations consolidated to a single source; duplicate registrations removed from theAppServiceProviderstub.
Upgrade
composer update lvntr/laravel-starter-kit
# Publish and run new migrations
php artisan vendor:publish --tag=starter-kit-migrations
php artisan migrate
# Publish updated file-manager config (new share.* keys)
php artisan vendor:publish --tag=starter-kit-config --force
# Publish new admin page and controller stubs
# WARNING: customised stubs will be overridden — diff first
php artisan vendor:publish --tag=starter-kit-stubs --force
# Add new permissions and reset permission cache
php artisan db:seed --class=PermissionResourcesSeeder
php artisan permission:cache-reset
New permissions: system.health.view, share-media, revoke-share-media, api-clients.create, api-clients.read, api-clients.update, api-clients.delete, api-tokens.create, api-tokens.read, api-tokens.delete.
Behaviour changes:
confidential=falseauthorization_code Passport clients can no longer be created via the UI. Existing DB records are unaffected.- Personal Access Token minting:
user_idbody field removed; to mint a PAT for another user use an artisan command or a custom action. - If your
AppServiceProviderstub has duplicate Passport scope /Gate::beforeblocks, remove them —StarterKitServiceProviderhandles this now.
2026-05-06 -v.13.5.2
Patch release — Settings security consolidation, FileManager restore fix and i18n improvements
Settings now consolidates Auth and Turnstile into a single Security tab and adds a Storage Quota card that visualises disk usage. The File Manager trash restore bug is fixed: the trash view now only shows root-level deleted items so single and bulk restore always succeed without the "parent in trash" error. All File Manager component text sizes are standardised to text-lg (14 px), confirmation dialogs are translated via trans(), and filter pill labels are internationalised. Existing apps should run composer update lvntr/laravel-starter-kit && php artisan sk:update && npm run build.
Added
SecurityTab.vueconsolidates Authentication and Cloudflare Turnstile settings into one tab, replacing the removedAuthTab.vueandTurnstileTab.vuestubs.StorageQuotaCard.vuedisplays disk-wide storage quota usage as a progress bar in the Settings panel.SettingsDefaultsQuerynow includesstorage_usage(used_bytes,quota_bytes) in the Inertia payload.- i18n keys added in
sk-setting(security/storage section labels),sk-file-manager(filter pill labels:all,image,video,pdf,audio,archive) andsk-common(confirmation dialog strings). config('file-manager.settings.enable_trash')— new config key that controls soft-delete vs hard-delete for the entire FileManager.true(default) sends deleted files and folders to Trash;falsepermanently deletes immediately. BothDeleteFileActionandDeleteFolderActionread the config at delete time. The value is shared automatically via Inertia (fileManagerSettings.enable_trash) so the Vue component falls back to the config without needing the:enable-trashprop — the prop can still be passed to override per-instance.
Fixed
- Trash restore bug.
TrashContentsQuerynow only returns root-level trashed items. Items whose parent folder was also in trash were listed as independent items, making both single and bulk restore fail with "Cannot restore: the parent folder is also in trash". Root-level filtering ensures restore operations always start from the top of the tree.
Changed
- FileManager minimum text size standardised to
text-lg(14 px) acrossFileManager.vue,FileGrid.vue,FileManagerSidebar.vueandFileManagerStats.vue. useConfirmcomposable confirmation strings moved totrans()calls using newsk-commontranslation keys.Admin/Files/Index.vuesimplified — unnecessary wrapper<div>removed.- File Manager tab — Video/Audio upload toggles now use the same checkbox-grid layout as Images.
Removed
AuthTab.vueandTurnstileTab.vuestubs — content merged intoSecurityTab.vue.sk:updatecleans them up automatically viaDEPRECATED_PATHS.
Upgrade
composer update lvntr/laravel-starter-kit
php artisan sk:update
npm run build
2026-05-05 -v.13.5.1
Patch release — NPM exports fix, sk:publish improvements, storage quota and upload validation
NPM package main and exports paths are corrected to match the actual file structure. Individual sk:publish tags now work correctly. Storage quota is configurable in GB from Admin Settings > File Manager, and upload requests now return a localised error when the quota is exceeded. Existing apps should run composer update lvntr/laravel-starter-kit && php artisan sk:update && npm install && npm run build.
Fixed
- NPM package
mainandexportspaths now reflect the actual file structure (resources/js/components/Lvntr-Starter-Kit/...). FileManager export added. sk:publishindividual tags (form,datatable,tabs,skeleton,ui) had broken source paths referencing the old structure; corrected with theLvntr-Starter-Kit/segment.vendor:publish --tag=starter-kit-componentsnested path bug resolved. Was producingresources/js/components/Lvntr-Starter-Kit/Lvntr-Starter-Kit/...; now publishes directly toresources/js/components/Lvntr-Starter-Kit/.vendor:publish --tag=starter-kit-file-manager-componentsis now active. Source path pointed to the old directory name (file-manager); realigned with the actual directory (Lvntr-Starter-Kit/FileManager).index.tsbarrel — 9 missing component exports added:EditorInput,EditorImagePicker,EditorColorPalette,TranslatableInput,ImageLightbox,FilePreviewModal,ToggleFeatureCard,MimePickerField,SkTag.
Added
sk:publish --tag=filemanager— new tag for publishing the FileManager UI separately.sk:install --without-ai-skill— skip AI skill publishing (stubs/.claude/skills/) for consumers that don't use the Claude Code skill bundle..gitattributes— Composer archive now excludestests/,docs/,.github/,plan-docs/,package-audit-notes/and other development-only paths; smaller archive size..npmignore— NPM package excludes__tests__/,*.spec.*,*.test.*(root and subdirectories; compatible with npm 11 behavior).- Disk-wide storage quota (
storage_quota_gb). A single quota in GB can be set from Admin Settings > File Manager (default 10 GB). Covers all contexts (user,global, custom morph map entries) including trash (withTrashed). - Upload quota validation.
UploadFileRequest::withValidator()adds a quota check; when exceeded the request returns HTTP 422 with a localisederrors.quota_exceededmessage.
Removed
- Duplicate domain commands removed from stubs:
EnvSyncCommand,MakeDomainCommand,RemoveDomainCommand. They continue to run from vendor as the single source.sk:updatecleans them up in existing consumer projects viaDEPRECATED_PATHS. App\Http\Responses\ApiResponse.phpstub removed. AStarterKitServiceProvideralias guard mapsApp\Http\Responses\ApiResponse→Lvntr\StarterKit\Http\Responses\ApiResponseonce the consumer file is deleted; existinguse App\Http\Responses\ApiResponse;imports continue to work unchanged.Lvntr\StarterKit\Enums\PermissionEnumremoved from vendor. Canonical location isApp\Enums\PermissionEnum(under stubs). No vendor references existed (confirmed by grep). If your code imports this namespace directly, update it toApp\Enums\PermissionEnum.
Changed
sk:publishis now the primary publish command. Granular interactive flow with namespace rewrite support.vendor:publish --tag=starter-kit-*is kept for backward compatibility butsk:publishis now the recommended path in install and command docs.ResolvesMediaModel::computeStorageUsed()signature changed (internal trait). No longer accepts a parameter; returns the disk-wide total viaMedia::withTrashed()->sum('size'). Previous behavior was per-context (model_type+model_idfiltered). If your app extends this trait and callscomputeStorageUsed($context), remove the argument.FolderContentsQuery,FavoritesContentsQuery,TrashContentsQuery—stats.storage_quotafield added (bytes).FileManager.vue— hardcodedSTORAGE_QUOTA_BYTESconstant removed;quotaBytesis now computed fromstats.storage_quota. The quota sidebar hides (v-if="quotaBytes > 0") when quota is zero or undefined.
Upgrade
composer update lvntr/laravel-starter-kit
php artisan sk:update
sk:update output will list 4 paths under "Removed" — this is expected.
2026-05-05 -v.13.5.0
Major release — Vendor-first runtime and frontend UI lib
The starter kit runtime moves entirely to vendor. FileManager backend, shared base classes, traits, helpers, middleware, ApiResponse and the route loader now live under vendor/lvntr/laravel-starter-kit/src/ with the Lvntr\StarterKit\ namespace. The frontend component library (DatatableBuilder, FormBuilder, TabBuilder, FileManager, Skeleton, ui) is also now canonical inside the package, consumed by the app via vendor symlink. Existing apps only need composer update; no file changes, no route names break, and php artisan migrate returns "Nothing to migrate". Frontend migration to vendor is fully opt-in. Upgrade instructions: UPGRADE.md.
Changed
- Vendor-first architecture. Package runtime no longer flows through stubs — it runs directly from
vendor/.sk:installpublishes skeleton files (auth, layout, user/role/settings domain, config); it no longer copies FileManager and Shared layers intoapp/. sk:updatesimplified. No file copying for vendor runtime;composer updateis enough. Hash-tracked stubs (auth/layout/user/role/settings) retain their existing diff/notify behaviour.- Frontend UI lib relocated.
resources/js/components/Lvntr-Starter-Kit/{DatatableBuilder,FormBuilder,TabBuilder,FileManager,Skeleton,ui,index.ts}is now the canonical package location. Apps consume it via vendor symlink. stubs/vite.config.tsalias updated. New installs get@lvntr/componentspointing tovendor/lvntr/laravel-starter-kit/resources/js/components/Lvntr-Starter-KitwithpreserveSymlinks: trueand vendor path in theComponents({ dirs })array.FileManagerActionabstract base +ResolvesMediaModeltrait. Resolves the Media model viamedia-library.media_modelconfig; app-specificApp\Models\Mediaoverrides (e.g. with SoftDeletes) work without changes.Http/Requests/FileManager/UploadFileRequest. Protected methods — overridable on the app side (e.g. Settings integration).
Added
src/Domain/FileManager/— Actions, DTOs, Queries, Services, Support underLvntr\StarterKit\Domain\FileManager\in vendor.src/Domain/Shared/— BaseAction, BaseDTO, ActionPipeline, PipeableAction underLvntr\StarterKit\Domain\Shared\in vendor.src/Traits/— HasActivityLogging, HasMediaCollections underLvntr\StarterKit\Traits\in vendor.src/sk-helpers.php—to_api(),definition(),definitionLabel(),sk_locale_keys(),sk_default_locale(),format_date()withfunction_existsguards in vendor.src/Http/Responses/ApiResponse.php—{success, status, message, data, errors?}envelope preserved, moved to vendor.src/Http/Middleware/— CheckResourcePermission, SecurityHeaders underLvntr\StarterKit\Http\Middleware\in vendor.src/Http/Controllers/FileManagerController.phpandsrc/Http/Requests/FileManager/*— in vendor.src/Console/Commands/PurgeFileManagerTrashCommand.php—file-manager:purge-trashsignature preserved.src/Exceptions/— ApiException, ApiExceptionHandler in vendor.src/Facades/FileManager.php— single-line route mount viaFileManager::routes().src/routes/file-manager.php— 19 routes, all names preserved exactly. Consumer's own route file takes precedence.database/migrations/— 3 FileManager migrations, filenames and content preserved exactly.config/file-manager.php—models.*andsettings.*keys added.
Deprecated
sk:sync(PackageSyncCommand). No longer needed with the Composer path-repository symlink workflow. The--forceescape hatch is preserved.
Upgrade
composer update lvntr/laravel-starter-kit
php artisan migrate
Existing app/Domain/FileManager/, app/Domain/Shared/, app/Traits/, app/Helpers/sk-helpers.php and related files stay in place and continue to work. Migrating them to the vendor versions is completely optional. Frontend cleanup (switching the Vite alias to vendor path and removing the app-side copy) is also opt-in. See UPGRADE.md for both guides.
2026-05-04 -v.13.4.10
Minor release — Translatable FormBuilder fields and Sample Contents reference module
FormBuilder now supports multi-language text fields out of the box. Three new builders — FB.translatableText(), FB.translatableTextarea() and FB.translatableEditor() — render one input per active language and submit JSON-ready locale maps for Spatie Translatable models. The release also adds backend helpers for validation, datatable search/sort and resource output, plus a shipped Sample Contents module that demonstrates the full pattern end to end. Existing apps should run composer update lvntr/laravel-starter-kit && php artisan sk:update && php artisan migrate && npm install && npm run build.
Added
- Translatable FormBuilder fields.
FB.translatableText(),FB.translatableTextarea()andFB.translatableEditor()render per-locale inputs driven by the active language list. They support locale filtering (onlyLocales,exceptLocales), inline or tabbed layouts, and locale label styles (badge,name,flag). - Backend translatable helpers.
HasTranslatableRulesgenerates FormRequest rules and validation labels per locale.TranslatableQueryHelpersprovides JSON-column search, locale-aware sorting andresourceShape()output for datatables and edit forms. - Locale helper functions.
sk_locale_keys()returns active locale codes in order, whilesk_default_locale()resolves the primary locale with a fallback toapp.fallback_locale. - Sample Contents module. A complete admin CRUD reference ships with a translatable model, migration, factory, domain actions/events/listeners, FormRequests, resource, datatable query, Vue pages and menu/permission entries.
- Documentation. New Translatable Fields and Çevrilebilir Alanlar guides document the full backend/frontend flow, migration strategy and Sample Contents reference implementation.
- Package dependency.
spatie/laravel-translatableis now part of the application dependency set for JSON-backed translated attributes.
Improved
- FormBuilder docs. The FormBuilder guide now lists the translatable builders and links to the dedicated guide.
- File Manager no-trash mode docs. The File Manager guide now clarifies that
enableTrash=falseroutes single and bulk delete operations to permanent deletion, includingforce_delete=truefor bulk deletion. - Lvntr builder skill docs. Project agent guidance for FormBuilder now includes the translatable field builders so future generated admin forms use the supported API.
Upgrade
Run migrations and rebuild frontend assets after updating:
composer update lvntr/laravel-starter-kit
php artisan sk:update
php artisan migrate
npm install
npm run build
Apps that already have custom language/settings handling should verify the active language list used by general.languages. Existing plain string columns are not migrated automatically; convert them to JSON with a staged migration before switching a model attribute to Spatie HasTranslations.
2026-05-02 -v.13.4.9
Minor release — File Manager favorites, trash, restore, permanent delete, copy and rename
File Manager now ships the feature set that was previously visible as placeholders in v13.4.8. Favorites and Trash are real quick-access views, folder/file tiles can be starred, deleted items move to trash by default, trash items can be restored or permanently deleted, and the trash view has an Empty Trash action. Files can also be duplicated and renamed from the context menu. This release adds two migrations (file_favorites and soft deletes on media), new backend actions/queries/requests, new File Manager routes, extended EN/TR language keys, and a daily file-manager:purge-trash scheduled command. Existing apps should run composer update lvntr/laravel-starter-kit && php artisan sk:update && php artisan migrate && npm install && npm run build.
Added
- Favorites. New
file_favoritestable andFileFavoritemodel store starred folders/files per owner context.FavoritesContentsQuerypowers the sidebar Favorites view,FolderContentsQuerynow annotates items withis_favorited, and the grid/context menus expose Add/Remove Favorite actions. - Trash and restore flow. Files and folders now soft-delete into Trash when
enableTrashis true.TrashContentsQuerypowers the Trash quick view, deleted tiles show their deleted timestamp, and trash context menus switch to Restore / Permanently Delete actions. - Empty Trash.
EmptyTrashActionandDELETE /file-manager/trash/emptypermanently delete all trashed File Manager items for the current context; files are removed before folders and folders are deleted post-order so nested trees clear safely. - File copy and file rename. Files can be duplicated with copy-safe names such as
photo (copy).jpg/photo (copy 2).jpg, and renamed through the shipped dialog andPATCH /file-manager/files/{media}endpoint. - Trash purge command.
php artisan file-manager:purge-trash --days=7permanently deletes File Manager trash older than the selected age. It is scheduled daily fromroutes/console.php. enableTrashprop.FileManagerdefaults to soft-delete behaviour; setting:enable-trash="false"restores immediate permanent deletion semantics for projects that do not want a trash workflow.
Security
- Context validation centralised.
FileManagerContextRequestnow validates and resolves the current File Manager context consistently across virtual views and item mutations, closing gaps where favorites/trash endpoints could drift from the regular folder-content checks. - Soft-delete scope hardening. Restore, permanent-delete, copy, rename and favorite actions now explicitly scope items to the current context and use
withTrashed()/onlyTrashed()where needed, preventing cross-context access and ensuring trashed items are found only in the intended paths. - Folder restore cascade guardrails. Restoring a trashed folder restores its descendant folders and File Manager media in a transaction. If its parent is still trashed, restore is refused until the parent is restored first; if the parent was permanently deleted, the item is restored to root to avoid an orphan.
Fixed
- Bulk force delete can now find trashed items.
BulkDeleteActionuseswithTrashed()whenforce=true, so permanent deletion from the Trash view no longer misses items that are already soft-deleted. - Language key collision fixed.
labels.detailsis now the details-section array, while the action label moved tolabels.details_action; this prevents the file details dialog labels from being overwritten by the context-menu action string. - Collection scoping tightened. Trash purge and permanent delete affect File Manager media (
collection_name = files) without touching avatars, logos, editor uploads or other MediaLibrary collections.
Upgrade
Run migrations after update:
composer update lvntr/laravel-starter-kit
php artisan sk:update
php artisan migrate
npm install
npm run build
No breaking API response change. Apps that customised File Manager stubs should compare their local files with the shipped updates before using sk:update --force, especially FileManager.vue, useFileManager.ts, FileGrid.vue, FileManagerController.php, routes/web/file-manager-route.php, lang/{en,tr}/sk-file-manager.php, the new requests/actions/queries, and the two migrations.
2026-04-30 -v.13.4.8
Minor release — File Manager UX overhaul (sidebar + stats + details + search)
File Manager UX overhaul — the same backend, same routes, same media table; a new shell. The single-column grid is replaced by a sidebar + main-column layout, with three new shipped components (FileManagerSidebar, FileDetailsDialog, FileManagerStats), a top-bar search box that filters the current folder client-side, and an expanded right-click menu with new entries (Open in new tab, Preview, Share, Copy, Rename, Add to Favorites, Details). All previously documented behaviour — uploads, drag-and-drop move, bulk delete, image lightbox, preview dialog, custom contexts, settings, permissions — works exactly as before; the change is purely shipped frontend (FileManager.vue + the three new components + types.ts + lang/{en,tr}/sk-file-manager.php). No new composer or npm dependency, no migration, no config, no permission entry. Existing consumer apps run composer update lvntr/laravel-starter-kit && php artisan sk:update && npm install && npm run build to pick up the patches; no breaking change.
Added
-
FileManagerSidebar.vue— left-rail with circular storage-usage ring, quick-access list, folder tree, "New Folder" button. The storage ring uses an SVG circle with acircumference - dashOffsetfill and a colour-band threshold (primary < 70 %, amber 70–90 %, rose ≥ 90 %); used bytes come fromfm.contents.stats.total_size, the quota is currently a sane visual default of 10 GB until a backend setting is wired. The folder tree reuses the samefm.treedata the move modal already loads. Quick-access targets: All Files resets to the root sorted by name asc, Recently Uploaded resets to the root sorted by date desc, Favorites and Trash show the newcoming_soontoast as placeholders for an upcoming feature. -
FileDetailsDialog.vue— file details modal showing Name, Type, Size, Uploaded, Folder, and (for images) Dimensions. Image dimensions are loaded async — the dialog kicks off a hiddennew Image()againstfile.urland pushesnaturalWidth × naturalHeightinto the rendered row whenonloadfires. The dialog ships with a "Download" footer button that reuses the samedownloadFilehandler as the right-click menu, so the action surfaces stay aligned. Wired up from the new "Details" entry in the file context menu. -
FileManagerStats.vue— top-bar stats widget (Total Files, Total Size, Folder Count, Favorites, Last Upload). Renders a horizontal row of icon-tinted cards (bg-{colour}-100in light,bg-{colour}-900/40in dark). Folder count traverses the full nested tree (flattenTree(fm.tree.value)); last-upload reflects the most-recentcreated_atin the current folder, formatted as "Just now / X min / X hr / X d / locale-date" via the newstats.time_*keys. -
Top-bar search.
IconField+InputTextstrip above the body filtersfm.contents.foldersandfm.contents.filesbyname/file_name(case-insensitiveincludes), surfaced via the newfilteredFolders/filteredFilescomputeds. Filter is local to the rendered folder; navigating clears it implicitly the next timefm.loadContents()runs. -
Expanded file context menu — Open / Preview / Download / Share / Move / Copy / Rename / Add to Favorites / Details / Delete. "Open" now opens the file in a new tab (
window.open(file.url, '_blank', 'noopener,noreferrer')); "Preview" keeps the existing lightbox / dialog flow; "Share" copies the absolute file URL to the clipboard (navigator.clipboard.writeText(...)) with a localised "Link copied" toast on success and thecoming_soontoast on permission refusal; "Details" opens the new dialog; "Copy", "Rename", "Add to Favorites" are placeholders for upcoming features. The destructive Delete row gets a newfm-menu-dangerclass so it can be styled distinctly. -
Folder context menu — adds "Add to Favorites" (placeholder) before Delete. Same
coming_soontoast pattern as the file-menu placeholders. -
types.ts— addsViewMode = 'grid' | 'list'andQuickView = 'all' | 'recent' | 'favorites' | 'trash'.ViewModeis reserved for an upcoming list-view renderer (currently grid-only);QuickViewis consumed by the sidebar quick-access flow. Existing exports unchanged. -
lang/{en,tr}/sk-file-manager.php— new keys. Top-level:link_copied,coming_soon. Labels:upload_new,preview,share,copy,add_to_favorites,details,search_placeholder,view_grid,view_list,files_section,folders_section,no_results. New nested groups:labels.sidebar.*,labels.stats.*,labels.details.*.
Removed
- Legacy header back-button + sort dropdown removed from
FileManager.vue. The previous shell had a←back button +Selectdropdown for sort key + a direction-toggle button in the header; navigation now happens through the sidebar (folder tree + breadcrumb) and sorting is driven by the quick-access flow ("Recently Uploaded" =setSort('date', 'desc')). TheuseFileManagercomposable still exposessetSort/toggleSortDirectionfor direct callers.
Upgrade
No breaking changes. Existing consumer apps run composer update lvntr/laravel-starter-kit && php artisan sk:update && npm install && npm run build — sk:update will pick up the new shipped files and the extended language keys. The data shape on the wire is unchanged; backend is unchanged.
2026-04-26 -v.13.4.7
Patch release — silence duplicate Link extension warning in EditorInput
Single-fix patch — silences the Duplicate extension names found: ['link'] warning Tiptap printed when EditorInput booted. Tiptap v3's @tiptap/starter-kit started bundling the Link extension by default, but our editor was still pushing @tiptap/extension-link through the optional props.links branch with our own openOnClick: false, autolink: true config — so two link registrations went into the same editor. The fix is a single config flag on the StarterKit call (link: false) so the bundled copy is disabled and our manual-push branch stays the single source of truth. Behaviour is identical for both props.links === false (no Link at all) and props.links === true (manual-push only); only the console noise is gone. Existing consumer apps run composer update lvntr/laravel-starter-kit && php artisan sk:update — no migration, no config, no breaking change.
Fixed
EditorInput.vue— duplicate Link extension warning silenced. Tiptap v3's@tiptap/starter-kitbundles the Link extension by default; the editor was also pushing@tiptap/extension-linkthrough the optionalprops.linksbranch, so the editor booted withDuplicate extension names found: ['link']in the console.StarterKit.configure({ heading: { levels: [2, 3, 4] }, link: false })disables the bundled copy so our manual-push branch (with our ownopenOnClick: false, autolink: trueconfig) is the only source.props.links === falsecleanly removes Link entirely;props.links === trueruns only the manual-push branch — same effective behaviour, no warning.
Upgrade
No breaking changes. composer update lvntr/laravel-starter-kit && php artisan sk:update picks up the patch — the fix ships in the same shipped Vue file sk:update already tracks; no extra step needed.
2026-04-26 -v.13.4.6
Patch release — Vite optional-peer-dep stub + sk:update package.json merge
Two related build/upgrade fixes that surface when consumers upgrade from a pre-EditorInput version of the kit (any 13.4.0 or earlier install) to 13.4.2+. The package's package.json no longer declares its @tiptap/* set as peerDependencies + peerDependenciesMeta.optional — those declarations were tripping Vite's optional-peer-dep stub fallback (__vite-optional-peer-dep:@tiptap/extension-table:@lvntr/starter-kit:false) when resolving from vendor/lvntr/laravel-starter-kit/, even on consumer apps that already had the deps installed at the project root. The result was "Table" is not exported by … at build time and does not provide an export named 'BubbleMenu' at runtime — both produced by Vite's stub module (export default {}; throw …) instead of the real package. And sk:update now mirrors sk:install's mergePackageJson() step so the new @tiptap/* set lands in the consumer's package.json automatically on upgrade — previously only fresh installs picked them up, leaving every consumer who upgraded from <13.4.2 to copy 16 dependency entries by hand. Stub-version-wins for shared keys, user extras preserved, idempotent on re-runs.
Fixed
-
Package
package.json— droppedpeerDependencies+peerDependenciesMetafor the@tiptap/*set. The package is composer-distributed (not on npm) so the peer-dep declarations had no effect onnpm install; their only practical impact was Vite'stryNodeResolvefallback. When a bare-import (import { Table } from '@tiptap/extension-table') couldn't be resolved through the normalnode_moduleswalk-up — easy to trigger when the package is invendor/, notnode_modules/— Vite checked the importer's nearestpackage.json, found the dep listed as an optional peer, and returned__vite-optional-peer-dep:<dep>:<parent>:<isRequire>instead of erroring. The stub is loaded asexport default {}; throw new Error("Could not resolve …")— no named exports, hence the misleading"Table" is not exported by …build error and the runtimedoes not provide an export named 'BubbleMenu'for the@tiptap/vue-3/menussubpath. Removing the declarations restores plainnode_modulesresolution which walks up to the project root and finds the real packages. -
sk:updatenow mergesstubs/package.jsoninto the consumer'spackage.json.UpdateCommandpreviously only touched files underapp/,config/,resources/androutes/— never the project'spackage.json. So the 16@tiptap/*entries that 13.4.2 added to the stub never reached consumers who upgraded viacomposer update lvntr/laravel-starter-kit && php artisan sk:update. The new step (4c inhandle()) mirrorsInstallCommand::mergePackageJson(): stub keys win at the root,array_merge-ddependencies/devDependencies(sorted), user extras preserved, only writes when the rendered JSON actually differs (so re-runs are no-ops). The summary surfaces the change aspackage.json (merged stub dependencies — run npm install)so the user knows to runnpm installafterwards.
Upgrade
No breaking changes. Existing consumer apps run composer update lvntr/laravel-starter-kit && php artisan sk:update && npm install && npm run build — sk:update will now sync the missing @tiptap/* entries into your package.json and Vite will resolve them against the real packages instead of the stubs.
2026-04-26 -v.13.4.5
Patch release — code-review sweep (API hierarchy + role-data + 2FA loading + permission directive + i18n)
Closes a small batch of findings from a follow-up code review of the v13.4.x surface. Two security/info-disclosure fixes (API user list now applies the same role-hierarchy filter the admin panel does, and the role JSON data endpoint now runs the same CanManageRoleQuery guard the edit/destroy actions do), one UX fix (the 2FA enable/disable buttons now reset their loading state on failure paths, not just the happy path), one latent-bug fix (the v-role directive read the wrong Inertia shared-prop key and silently always returned false), and one i18n cleanup (the useApi composable's error toasts and synthesized envelope messages now flow through sk-message.* keys instead of hardcoded Turkish strings). All changes are additive on the wire — same response shape, same status codes, same UI. Three regression tests guard the two security fixes. Existing consumer apps pick the patches up via php artisan sk:update; no migration, no config, no breaking change.
Security
-
Api/UserController::indexnow delegates toUserDatatableQuery— same role-hierarchy filter as the admin panel. Previously the API used a bespokeDatatableQueryBuilderchain that skipped thewhereDoesntHave('roles', sort_order < me)clauseUserDatatableQueryenforces. Result: a non-system_adminAPI consumer holdingusers.readcouldGET /api/v1/usersand see every higher-rank user — includingsystem_adminaccounts — whereas the admin UI hid them. The controller now method-injectsUserDatatableQueryand returns itsresponse($request->user())directly. The query's allowlists were extended with thefirst_name,last_name,email,status,id,created_atsortable keys (previously API-only) so the wire contract for legitimate API callers is unchanged. Covered by the newtests/Feature/Api/UserTest.php"hides higher-rank users from non-system_admin api callers" regression test. -
Admin/RoleController::datanow runsCanManageRoleQuerybefore returning role JSON.data()is the JSON sibling ofedit()(the admin role form prefetches it viauseApi().get('/admin/roles/{role}/data')).edit()anddestroy()already gated throughCanManageRoleQuery::check()to enforce the role hierarchy;data()did not — so a lower-rank admin could read the full permission set of a higher-rank role over JSON, even though the form they would render the data into is hierarchy-aware. The check is now inlined at the top ofdata()(abort(403)on mismatch), mirroringedit(). Covered by two newtests/Feature/Admin/RoleManagementTest.phpregression tests ("forbids non-system_admin from reading higher-rank role data" + the positive sibling for same/lower rank).
Fixed
-
2FA enable/disable buttons no longer get stuck on error.
Profile/components/TwoFactorTab.vuesettwoFactorProcessing = truebefore calling Fortify, but only reset it on the success branch. An axios 4xx/5xx (typical: an expired session, password-confirm timing out) or an Inertiarouter.reloaderror left the button spinner stuck until full page reload. BothenableTwoFactor()anddisableTwoFactor()now reset the flag in afinallyblock, so any failure surfaces as a re-clickable button + a toast (rather than a frozen UI). -
v-roledirective now reads the correct Inertia shared-prop key.resources/js/plugins/permission.tscheckedauth.roles, butHandleInertiaRequestsshares the user role names underauth.role_names. The directive silently always evaluated tofalse—<div v-role="'system_admin'">markup was never visible regardless of the actor's role. The plugin now readsauth.role_names. The duplicateuseCanexport inside the plugin file (which read the same wrong key) was removed too — the canonicaluseCan()lives at@/composables/useCanand was already correct, so application code was unaffected. The plugin file now exports only thePermissionPlugin(registersv-can+v-role). -
useApicomposable error messages flow throughsk-message.*i18n keys.resources/js/composables/useApi.tshad three hardcoded Turkish error strings (synthesized envelope on non-JSON response, network-failure toast detail, toastsummary). Replaced withtrans('sk-message.invalid_response'),trans('sk-message.request_failed', { status }),trans('sk-message.network_error'),trans('sk-message.error_summary'). The four new keys are added to bothlang/en/sk-message.phpandlang/tr/sk-message.php. EN-locale users no longer see Turkish copy when an API call fails outside the normal envelope path.
New
- Regression tests for the two security fixes.
tests/Feature/Api/UserTest.phpgains thehides higher-rank users from non-system_admin api callerstest — seeds the role hierarchy viaRoleEnumindex, mirrorsusers.read+adminrole into theapiguard (Spatie'sGuard::getDefaultName()switches toapiunderPassport::actingAs), assigns both web + api versions of the role to an admin user, and asserts the response excludes the higher-ranksystem_adminpeer + the actingsystem_adminuser but includes the same-rank admin peer.tests/Feature/Admin/RoleManagementTest.phpgains two:forbids non-system_admin from reading higher-rank role data(admin gets 403 on/admin/roles/{system_admin}/data) andallows non-system_admin to read lower-rank role data(admin gets 200 on/admin/roles/{user}/data).
2026-04-25 -v.13.4.4
Patch release — system-admin log viewer (/logs)
Adds a maintainer-only admin section for browsing, searching and deleting Laravel log files in storage/logs/. Self-contained — no new composer or npm dependency, no migration, no permission entry. Visible only to system_admin users; everyone else still sees the same panel as before. All additive.
Added
-
/logsadmin section — system-admin-only log viewer. A new sidebar item under "System" lists the contents ofstorage/logs/in anSkDatatable(filename, channel type, size, modified time, active flag), and a per-file viewer page applies structured filters (level, date range, keyword) over a cursor-paginated entry stream. Single + bulk delete are wired through the same endpoint with partial-success semantics — active files (today's daily log, anything written within the last 5 seconds) are refused per-file and reported back infailed[], the rest go through. Each delete batch dispatches aLogFilesDeletedevent; the newLogActivityForLogFilesDeletedlistener writes aspatie/activitylogentry underlog_name = system, so deletions surface automatically in Admin → Activity Logs. -
app/Domain/Logs/bounded context. Four DTOs (LogFileDTO,LogEntryDTO,LogEntryFilterDTO,DeleteLogFilesDTO), two queries (LogFileQueryfor the file list,LogEntryQueryfor streaming entries), one action (DeleteLogFilesAction), one event/listener pair, and a statelessLaravelLogParserservice.LogEntryQuery::paginate()reads the file withfopen('rb')+ 64KB-cappedfgets()and a byte-offset cursor, so memory stays bounded regardless of file size; multi-line stack traces are kept attached to the entry that opened them, and any line that appears before the first Laravel-format header (or in a file with no headers at all) surfaces as a single rawLogEntryDTO(is_raw = true, gray chip, hidden timestamp) so file content is never silently dropped. Raw entries are filtered out the moment any structured filter (level / from / to / keyword) is applied. -
logs.*named route group.routes/web/log-route.phpships five routes —index,dtApi,show,entries,destroy— wrapped inrole:system_admin. The{filename}parameter constraint ([A-Za-z0-9._-]+\.log) is enforced on bothshowandentries, so path traversal and non-.logrequests never reach the controller. The file is added to the$routesWithoutPermissionMiddlewareallowlist inroutes/web.phpbecause the section is role-gated, not permission-gated. -
lang/{en,tr}/sk-log.phptranslation file. All UI copy (filter labels, empty states, delete confirmations, failure reason codes) lives behind thesk-log.*namespace in both languages. The newsk-menu.logskey labels the sidebar entry.
Security
-
Path-traversal guardrail at three layers. The safe filename regex
^[A-Za-z0-9._-]+\.log$is enforced in (1) the route parameter constraint, (2)DeleteLogFilesRequestrules, and (3)DeleteLogFilesAction::execute()itself (defence in depth). Anything else returns alog.invalid_filenamefailure or a 404 from the route binding — the disk path is never built from raw input. -
Active-file deletion refused.
LogFileQuery::isActive()flags today's daily file (laravel-{today}.log) and any file with anmtimewithin the last 5 seconds.DeleteLogFilesActionrejects flagged files per-item withreason: 'active_file_protected', so a bulk submit cannot accidentally truncate the file Laravel is currently appending to. -
role:system_adminroute gate, no permission entry. The viewer is intentionally not added toconfig/permission-resources.php. Granting anadminrole does not unlock it; only the dedicatedsystem_adminrole does. Non-system-admin users get a 403 on the route and never see the menu item — the feature is invisible to them. -
Per-line read cap of 64KB.
LogEntryQuerycallsfgets($handle, 65536), so a pathological single-line entry of unbounded size cannot exhaust process memory. Long lines truncate cleanly without aborting the request.
2026-04-25 -v.13.4.3
Patch release — rich vertical tabs + datatable per_page upper bound
Brings a richer vertical tab presentation through the TB builder (icon tile, description line, trailing badge or check) and an opt-in upper bound on the ?per_page= query parameter handled by DatatableQueryBuilder. Both are additive — no breaking changes. sk:update ships the new TabBuilder Vue components, the rewritten _tabs.scss, and the EN/TR sk-setting.tab_descriptions language keys; composer update is sufficient for the package-tier max_per_page config.
Added
-
TB.item()rich vertical tab fluent methods. Four new fluent methods:.description(text)for a secondary line under the label,.iconColor(color)for a colored icon tile preset (13 colors:blue,amber,emerald,purple,teal,red,indigo,slate,pink,orange,cyan,green,yellow),.badge(value, severity?)for a trailing badge (5 severities:success,warn,info,danger,secondary), and.checked()for a trailing green check (takes precedence overbadge). Existing tab definitions render unchanged. The shipped Settings → General page uses the new API (per-tab description + icon color) as the canonical example. New i18n blocksk-setting.tab_descriptionscovers the seven settings tabs. -
STARTER_KIT_DATATABLE_MAX_PER_PAGEenv var +config('starter-kit.datatable.max_per_page'). Opt-in upper bound on the?per_page=query parameter forDatatableQueryBuilder. Defaults to100when the config key is absent.
Security
DatatableQueryBuilder—?per_page=upper bound enforced. Previously a client could send?per_page=99999and force the builder to materialise an entire table into a single payload. The new ceiling (config('starter-kit.datatable.max_per_page'), default 100) silently clamps the value — anything inside the cap behaves identically, so legitimate callers are unaffected.
Improved
- Vertical tab sidebar — PrimeVue Card wrap via
.isCard(true). Set at the tabs level (not per-tab), the vertical sidebar wraps in a Card with reduced internal padding. Combined with the new icon tile + description fields, the Settings page sidebar now matches modern admin-panel layouts out of the box.
Fixed
- Branding — legacy "Starter Kit 12" references. Two places still read "Starter Kit 12" —
config/scramble.phpAPI description andapp.blade.phpfallback title; both now read "Starter Kit 13".
2026-04-24 -v.13.4.2
Patch release — Tiptap editor input, password generator, dashboard welcome message + security hardening
Introduces a rich-text FB.editor() FormBuilder field (Tiptap v3 under the hood) paired with a server-side HtmlSanitizer utility, a crypto-safe password generator on FB.password(), and an admin dashboard welcome message authored through the editor on Settings → General. File upload gains an optional folder_name parameter so editor-scoped uploads stay grouped, and the FileManager now surfaces a dedicated error for 413 Payload Too Large. All additive — no breaking changes. sk:update ships the published files (new Vue components, HtmlSanitizer, language keys); composer update is sufficient for package-tier changes.
Added
-
Tiptap-based
FB.editor()FormBuilder input. A new form field type backed by Tiptap v3 with bubble menu, link / image / table / task list / text align / text color / text style and placeholder extensions. Toolbar layout is chosen via.toolbar('minimal' | 'standard' | 'full'); image uploads route through the FileManager context with an optional folder-grouping parameter, and companion components (EditorColorPalette,EditorImagePicker) cover the color and image picker flows. Translations live inlang/{en,tr}/sk-editor.php. Content flows through the newApp\Support\HtmlSanitizeron save, so only allowlisted tags / attributes / URL schemes are persisted. -
FB.password().generator()— crypto-safe password generator. Opt-in fluent method that adds a generate button next to the password field, backed bycrypto.getRandomValues(). Defaults are intentionally stricter thanPassword::defaults()(16 characters, mixed case + letters + digits + symbols) so every generated value passes the framework-wide password policy on the first submit. Paired with a rewritten custom eye toggle sopasswordandpassword_confirmationfields render identically insideInputGroupcontainers. PrimeVue<Password>is now only used when.feedback()opts in to the strength meter — every other usage falls through the lighterInputText + eyepath. Enabled on the admin User form out of the box. -
Admin dashboard welcome message. Settings → General gains an optional
welcome_messageWYSIWYG field rendered throughFB.editor(). The dashboard shares the sanitized HTML as an Inertia prop, andresources/js/pages/Admin/Dashboard/Index.vuerenders it inside ansk-prosecontainer viav-html. The value is sanitized on write (FormRequestprepareForValidationhook) and on read (DashboardController defense-in-depth pass) so pre-existing rows with hostile markup cannot surface even if the on-disk value drifts. -
File upload
folder_nameparameter.POST /file-manager/filesnow accepts an optionalfolder_namestring (nullable,max:100, strict regex: letters / digits / space / dash / underscore only — path-traversal and arbitrary-character risk closed at validation). When supplied,UploadFileAction::ensureManagedFolderatomically ensures a root-level folder with that name exists in the current context and stores the upload inside it. The welcome-message editor uses this to keep all inline image uploads grouped under a single "Welcome Message" folder without a read query ever writing side-effects. FrontendEditorImageUploadConfigexposes the same field viafolderName.
Security
-
App\Support\HtmlSanitizer— allowlist for tags, attributes, and URL schemes. New utility that strips every tag, attribute and URL scheme not on a small allowlist from editor payloads. URL handling flipped from blocklist to allowlist: relative URLs plushttp://,https://,mailto:andtel:are permitted — anything else (blob:,data:,file:,ftp:,javascript:,vbscript:) is rejected. Covered by a dedicatedtests/Unit/HtmlSanitizerTest.phpsuite. -
SettingService::normalizeValue()— HTML sanitize on every write path.setValue()andsetGroup()now pass every value through a sharednormalizeValue()hook. Keys listed in a newHTML_SAFE_KEYSwhitelist (currentlygeneral.welcome_message) are run throughHtmlSanitizer::sanitize()before hitting the database, so non-FormRequest writes — tinker sessions, scheduled commands, queued jobs — cannot leave unsanitized HTML behind. -
Dashboard welcome message — defense-in-depth read sanitize.
DashboardController::indexruns the stored welcome message throughHtmlSanitizer::sanitize()a second time before sharing it to Inertia. Historical rows written before the write-path sanitize landed are neutralised, and a drifted or manually-poisoned DB value cannot reach the browser. -
UploadFileAction::ensureManagedFolder— concurrency-safe managed folder creation. The ensure path runs insideDB::transactionwithlockForUpdateon the candidate row, falls back on aQueryExceptioncatch for the unique-constraint race, and restores soft-deleted folders viawithTrashed()instead of creating a duplicate. Combined, the three layers close the race window where two parallel editor uploads could either deadlock on the same folder name or resurrect a soft-deleted row by creating a sibling that trips the unique index. -
UploadFileRequest—folder_nameinput strictly validated. The new field usesnullable|string|max:100|regex:/^[\pL\pN _-]+$/u; path-traversal and arbitrary-character content is rejected at the FormRequest boundary, not downstream.
Improved
-
FileManager upload error messages. The client composable now recognises HTTP 413 (Payload Too Large) and surfaces the dedicated
too_largetranslation (EN + TR) instead of a generic failure string; every other non-200 response carries the raw status code alongside the message, so upload failures are easier to diagnose without opening the devtools network tab. -
Password field default render path. Beyond the
.generator()addition above, the defaultFB.password()render now usesInputText+ a custom eye toggle rather than PrimeVue<Password>. Fixes the long-standing issue where<Password>'s built-in eye icon disappeared insideInputGroupaddons, and makespassword/password_confirmationfields render identically.<Password>is still used when.feedback()is called (strength meter path). New i18n keys:generate_password,password_generated,password_generated_detail,show_password,hide_password(EN + TR).
Fixed
-
SettingsDefaultsQueryread path no longer writes. The previous release read the Settings → General screen and, as a side effect, tried tofirstOrCreatea "Welcome Message" folder throughresolveWelcomeMessageFolderId(). On installs with a soft-deleted folder of that name, the unique index rejected the insert and the admin saw a 500 on a pure read. The folder ensure path is now owned exclusively byUploadFileAction::ensureManagedFolderat upload time, andSettingsDefaultsQueryis side-effect-free again. The frontendwelcome_message_folder_idInertia prop binding is gone as well — the editor usesfolderNamedirectly. -
Editor upload — stale
blob:URLs no longer leak into the form payload.EditorInput.vuenow manually syncs the parentv-modelaftersetContent({ emitUpdate: false }), so replaced / broken<img src="blob:...">fragments from a just-completed upload no longer travel to the server in the submitted HTML.
2026-04-22 -v.13.4.1
Patch release — API response hardening + Postman/Apidog sync + OAuth UUID fix
This release bundles the end-to-end API response envelope rework (trace-id pipeline, centralised exception handler, leak-closing controller patches) with two new API client integrations (Postman and Apidog sync) and a pair of install-time fixes (OAuth UUID compatibility, automatic Passport personal access client). Most changes are additive (new body fields + headers, new admin buttons), but three API-response behavioural breaks matter for strict clients — see docs/UPGRADE.md. Fresh installs pick everything up automatically; existing projects should follow the upgrade guide. sk:update ships the published files; controller patches and the post-install Passport step are manual.
Security
-
Controller
$e->getMessage()leaks closed (11 sites).FileManagerController(bulkDelete/createFolder/renameFolder/moveItem/deleteFolder/upload/deleteFile),Api/UserController::destroy, andApi/Auth/AuthController::login+twoFactorChallengeswapped theto_api(null, $e->getMessage(), 4xx)pattern forthrow ApiException::*. The client-facing message is unchanged, but the response now routes through the central handler — thetrace_idis aligned, 500+ errors are logged,X-Correlation-IDis echoed. Moving away from rawLogicException::getMessage()closes the door on accidental internal-message leaks during future refactors. -
abort($code, 'msg')no longer leaks the raw message. TheHttpExceptionInterfacebranch now uses the fixeddefaultMessageForStatus()table instead of$e->getMessage().abort(400, 'SQL error: ...')now returns"Bad request."in the body; the internal detail only surfaces indebug.messagewhileAPP_DEBUG=true. Usethrow ApiException::badRequest('...')for controlled messaging. -
Api/AuthControllerreturnsUserResourceinstead of a raw User.register,login(default kind),twoFactorChallenge, andmenow producedata.userviaUserResource::toArray(). Raw Eloquent serialisation relied on$hidden; a future sensitive column could leak if forgotten. The resource makes the wire contract explicit.
Added
-
Postman sync — admin button + CLI. New "Sync to Postman" action on the API Routes page (and
php artisan postman:sync) pushes the Scramble OpenAPI spec through Postman's/import/openapiendpoint withfolderStrategy=Tagsso tags become folders. Each sync imports a fresh collection, persists the newly issued UID to the settings store, then best-effort deletes the previous collection — animport-first, delete-aftersequence so a transient Postman outage or invalid token never leaves the workspace without a working collection. Configuration: Settings → API Clients → Postman card (API Key + Workspace ID; collection ID is managed automatically). -
Apidog sync — admin button + CLI. Same pipeline pushes to Apidog's
POST /v1/projects/{projectId}/import-openapiendpoint with inline JSON input andOVERWRITE_EXISTINGmerge behavior. Also available asphp artisan apidog:sync. Configuration: Settings → API Clients → Apidog card (Access Token + Project ID). -
Settings → API Clients tab. Single tab hosts both Postman and Apidog configuration as separate cards. Secret fields (
postman.api_key,apidog.access_token) are encrypted at rest through the existingsensitive_keyslist inconfig/settings.php. The previousPOSTMAN_*.envkeys are no longer used — existing values are migrated into the settings table. -
Shared
OpenApiExporterhelper. Both sync Actions share a single exporter that runsscramble:export, writes to a per-request unique path understorage/app/postman/, and cleans up in afinallyblock — the CLI command and the admin UI button can run concurrently without racing on a shared file. The spec is emitted unchanged: no content-type rewriting, so the pushed collection mirrors the real server contract (clients are free to toggle the body view between raw / form-data in their own UI).
Improved
-
Success and error responses share a single
trace_id. The newAssignTraceIdmiddleware prepended to theapigroup generates a UUID per request and both branches (ApiResponse::toResponseon success,ApiExceptionHandleron error) pick it up from$request->attributes. Bodytrace_id+ headerX-Request-ID+ a sanitised echo of the client'sX-Request-IDasX-Correlation-ID. In support scenarios, client-side logs and server-side logs correlate via one id. -
ModelNotFoundExceptionmessage includes the model name."The requested resource was not found."→"User not found."(orRole,Product, …).ApiExceptionHandler::modelNotFoundMessageresolves it viaclass_basename($e->getModel()). Matches the previous AGENTS.md contract; no security impact since the model class name is already inferable from the URL. -
Retry-Afterheader propagated on 429 responses. All rate-limit headers fromThrottleRequestsException::getHeaders()(Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset) are copied to the response. Throttled clients can read the standard header instead of parsing it out of the message string. -
simplePaginate()support.to_api(Model::simplePaginate(15))no longer raises a type error; lightweight pagination with justmeta.has_moreis now supported.LengthAwarePaginatorandCursorPaginatorbehaviour unchanged. -
to_api(paginator, 'msg', 201)no longer loses pagination meta. Helper's paginator detection now runs before the 201/202 branches; batch-create style endpoints emit meta too (previous release serialised the paginator as a raw object — silent bug). -
ApiResponseDRY +final. The meta builder forpaginated()andpaginatedCollection()was extracted into a single private helper. The class is nowfinalto prevent subclass invariants drifting. No behavioural change to the controller return-type signatures; public API surface unchanged. -
Scramble
ApiResponseExtensionschema descriptions enriched. Each envelope field now has a definition + example + validation-rule description. Multi-status schema (distinctResponseobjects for 201 / 204 / 4xx / 5xx) is deferred —TypeToSchemaExtensiondoes not model it directly, soOperationExtensionwill take over in a follow-up.
Fixed
-
OAuth migrations UUID-compatible.
oauth_access_tokens.user_idandoauth_auth_codes.user_idare nowforeignUuid(previouslyforeignId/bigint unsigned);oauth_clients.owner_*is nownullableUuidMorphs. Combined with the UUIDusers.idprimary key shipped by this starter kit, the previous mismatch surfaced asSQLSTATE 1265: Data truncated for column 'user_id'on login — the API login flow is now clean out of the box. -
site:installprovisions the Passport personal access client automatically. Apassport:client --personal --provider=usersstep was added betweenpassport:keysand the admin-user seed. Fresh installs can issue API tokens right away; previously the operator had to remember to run that command manually aftersite:install. -
202 Accepted dead code removed. The
'Operation queued.'fallback forto_api($data, '', 202)never fired (the default$messagewas truthy). Helper simplified to a single logical flow. -
ApiResponse::toResponse()honours the$requestparameter. The previous implementation accepted theResponsable::toResponse($request)signature but ignored the argument — integration with the new middleware depends on this parameter, which is now actually consumed. -
Exception handler
matchordering criticality documented.ApiException extends HttpException, so it must be matched before theHttpExceptionInterfacebranch — otherwise custom API exceptions would fall through to the generic abort() handling. The fragile ordering is pinned by a comment and by the regression suite (tests/Feature/Api/ApiResponseTest.php).
New
-
Regression test file:
tests/Feature/Api/ApiResponseTest.php(16 tests, 57 assertions). Covers the envelope shape, exception → status mapping, trace id agreement, empty 204 body,Retry-Afterpropagation,debugguarded byAPP_DEBUG=true, and the sanitisedX-Correlation-IDecho. Example copy available atvendor/lvntr/laravel-starter-kit/tests/examples/ApiResponseTest.php. -
Expanded
sk:updatecoverage.app/Http/Middleware/AssignTraceId.phpandapp/Helpers/sk-helpers.phpjoined the safe-update list;php artisan sk:updatenow syncs both automatically.ApiResponse.phpandApiExceptionHandler.phpwere already tracked.
Breaking
Detailed migration steps in docs/UPGRADE.md. Summary:
abort($code, 'custom message')no longer surfaces the message — usethrow ApiException::*instead.ModelNotFoundExceptionmessage now includes the model name ("User not found."). Frontend regex matches may need to loosen.Api/Auth/AuthControllerdata.useris limited toUserResource::toArray()output. If you depended on a raw-model field, extend the resource.
2026-04-21 -v.13.4.0
Minor release — Security hardening sprint
A parallel code-review sweep surfaced ~37 findings — 13 HIGH, 14 MEDIUM, 4 LOW. 36 are closed in this release; 1 HIGH (Passport private-key rotation in git history) is a manual operator step. Most patches touch published files (the files sk:install copies into your app), so existing consumer apps need to apply the diffs in docs/UPGRADE.md. Fresh installs pick everything up automatically. The rare package-tier changes (HSTS preload, stub updates) arrive via composer update lvntr/laravel-starter-kit.
Security
-
Self-delete blocked on
UserPolicy::delete+ null guard on APIUserController::destroy.UserPolicy::deletepreviously returnedtruewhen actor === target, so any authenticated user holdingusers.deletecould remove themselves viaDELETE /api/v1/users/{self}. The self-branch now returnsfalse— the only supported self-removal path is the password-confirmed Fortify flow in Profile.Api\UserController::destroyalso returns a clean 401 when$request->user()is null (stale/expired bearer), replacing the previous(string) null = ''cast that would log an empty performer id. -
CreateRoleAction+UpdateRoleActionwrap role + permission sync inDB::transaction.Role::create(...)followed by->syncPermissions(...)ran outside a transaction; a permission-cache race or a connection drop between the two writes could leave a role row with no permissions. Both actions now run insideDB::transaction(...);RoleCreated/RoleUpdateddispatch after commit so listeners observe a consistent state. -
UpdateAuthSettingsActionwraps the 2FA revoke loop inDB::transaction. When the admin togglesauth.two_factoroff, the action writes the setting and then clearstwo_factor_secret/two_factor_recovery_codes/two_factor_confirmed_aton every user. A failure mid-loop used to leave the system in a half-revoked state — the setting said "2FA off" but some users still had active TOTP secrets. The full operation is now atomic. -
LogoutUserActionnull-safe token revoke. The API logout endpoint called$user->token()->revoke(); if the request hit the controller without an active access token (stale token, cleared cache, worker race) the chained call threwError: Call to a member function revoke() on nulland the endpoint 500'd. Now uses?->revoke()— returns a clean 204 even when the token is already gone. -
FileManager subtree walks reduced from N queries to 1.
BulkDeleteAction::collectDescendantIdsandDeleteFolderAction::collectDescendantIdsissued aFileFolder::findper hop when walking the subtree of a folder being deleted — a 50-level tree meant 50 serial queries, and the cost grew with siblings, giving attackers a request-timing DoS knob. Both actions now load the owner-scoped(id, parent_id)map in oneselectand walk the tree in PHP with a visited-set cycle guard for corrupt data. -
SMTP
encryption=nonenow disables TLS correctly. The shipped Mail settings screen offered a "No encryption" option, butSettingsServiceProviderrebroadcast the literal string'none'intoconfig('mail.mailers.smtp.encryption'). Laravel's SMTP transport treats any non-null value — including'none'— as "use this TLS mode", so saved "No encryption" configurations fell back to the default STARTTLS upgrade on the first connect and could fail against servers that do not offer it. The provider now maps'none' → nullon the outbound config write. -
ApiExceptionHandler— exception-message leak +X-Request-IDlog injection. Thedefaultarm of the exception→status mapping returnedconfig('app.debug') ? $e->getMessage() : 'A server error occurred.'; in any environment whereAPP_DEBUGwas accidentally left on, unhandled exceptions leaked stack-trace-grade detail to API consumers. The handler now returns the generic message unconditionally; debug details live only inLog::errorplus thedebugblock that is already gated onAPP_DEBUG. The trace id is now always server-generated viaStr::uuid(); anyX-Request-IDheader sent by the client is accepted only as correlation metadata after a charset + length-cap sanitiser ([A-Za-z0-9._-], ≤128 chars), then logged asclient_request_id— a malicious client can no longer inject a CRLF payload or a fake trace id into the application log. -
SecurityHeadersHSTS directive gainspreload. The baseline HSTS header moved frommax-age=31536000; includeSubDomainstomax-age=31536000; includeSubDomains; preload, making the deployment eligible for the HSTS preload list. Ships from the packagesrc/— picked up automatically bycomposer update. -
Password policy raised to 10+ / mixed case / digits / symbols.
AppServiceProvidernow installs a project-widePassword::defaults(...)that every FormRequest relying on the default picks up automatically (registration, password reset, password confirm, profile password change). Existing users' passwords are not invalidated — only new passwords are measured against the stronger rule. -
Axios CSRF + credential defaults.
resources/js/app.tsnow setsaxios.defaults.withCredentials = true,xsrfCookieName = 'XSRF-TOKEN',xsrfHeaderName = 'X-XSRF-TOKEN', plusX-Requested-With: XMLHttpRequestandAccept: application/json. The admin UI calls Fortify endpoints (2FA, sessions, password-confirm) directly via Axios; withoutwithCredentialsand the XSRF header the browser sent the session cookie but not the CSRF token on mutating requests, so a compromised origin could interact with the session without the CSRF check the web flow relies on. -
2FA QR code rendered through
<img src="data:image/svg+xml;base64,...">instead ofv-html. Fortify returns the QR code as an SVG string. The previousv-html="qrCodeSvg"worked but would have evaluated<script>oronloadattributes in the SVG if a man-in-the-middle (or a compromised Fortify override) slipped them in. The new approach base64-encodes the SVG into an<img>data URL — the<img>sandbox does not execute inline scripts, even if the SVG contains them. -
useDefinition.load()/loadAll()no longer fliploaded.value = trueon a failed fetch. The composable is the one-stop loader for the definition JSON that drives datatable / form option dropdowns. It previously chained.then(r => r.json())directly — if the fetch failed (network error, 500, parse failure)loaded.valuestayedtrueand the UI kept rendering stale / empty option lists without any console feedback. Both methods are now wrapped intry/catch,res.okis checked, errors surface to the console, andloaded.valuestaysfalseon failure so consumers can retry. -
Eleven
FormRequest::authorize(): return true;offenders closed. The following requests — admin user store, API user store, admin role store, admin settings (auth/general/mail/storage/filemanager/turnstile), test-mail, destroy-sessions — now delegateauthorize()to the matching*.create/*.updatepermission (destroy-sessions checks$this->user() !== null). TheCheckResourcePermissionmiddleware already enforced these at the route level, but moving the check into the request closes the defense-in-depth gap that would open the moment a controller action was invoked off-route (tests, internal dispatch) or the action map drifted out of sync with new route names. Public auth endpoints (Api/Auth/*Request) and FileManager context-based requests are intentionally left alone. -
2FA challenge is now strictly single-use.
TwoFactorChallengeActionpreviously left theapi:2fa_challenge:{uuid}cache entry intact on a wrong TOTP / wrong recovery code / empty submit, so an attacker with a valid challenge id got the full 5-minute TTL ×throttle:5/minwindow to try codes. Every failure arm now callsCache::forget($cacheKey)— the challenge id works exactly once; subsequent attempts hitinvalidChallenge()and the client must re-login to get a fresh uuid. -
SettingService::getValue/getGroupread from theallGrouped()cache +setGroup()wrapped inDB::transaction. The hot read path previously ran one query per call even though a cache layer existed for the fullallGrouped()result. Settings-heavy request paths (Dashboard, FileManager, Admin pages) saved a handful of round-trips per request. The bulk write path is also now atomic — a partial failure during a multi-setting save no longer leaves the DB in a mixed state. -
MoveItemRequest— typeditem_idbased onitem_type. The rules used to accept anyitem_idvalue for anyitem_type. The effective rule is nowinteger|min:1foritem_type=fileanduuidforitem_type=folder, matching the DB schema;item_typeitself usesRule::in([...])instead of thestring|in:...string form. -
DeleteFolderRequest— explicit FormRequest replaces a bareRequest.FileManagerController::deleteFolderpreviously accepted a rawRequest, built the context in the controller, and called the authorizer directly. The newDeleteFolderRequestextendsFileManagerRequest, runs the shared context rules, and exposes$request->context()— identical surface to the other FileManager endpoints; controller drops two lines of boilerplate. -
UserController::uploadAvatarruns an explicitGate::authorize('update', $user).UploadAvatarRequest::authorize()already delegates toUserPolicy::updatewhen a{user}route param is bound, but the redundant Gate call in the controller mirrors the belt-and-braces pattern used on view/update/delete and keeps the check visible when reading the controller in isolation.
Security — manual operator step
- GV-H1 — Passport private keys rotation.
storage/oauth-private.keyandstorage/oauth-public.keylive in git history for legacy installs that committed them before the.gitignorerule landed. docs/UPGRADE.md §6 documents thegit filter-repo+passport:keys --force+passport:purge+ team-widegit reset --hardsequence; this cannot be automated by the package. If your repo never committed the key files, skip this step.
Changed
-
LOG_LEVELdefault is nowerror..env.examplepreviously shippedLOG_LEVEL=debug, which in production (if committed verbatim) fills the log with SQL traces, Passport token debug info and similar — noisy and occasionally sensitive. Production profiles should shiperrororwarning. -
laravel/tinkermoved torequire-dev. Tinker is a developer convenience — shipping it as a production dependency pulled PsySH and its transitive chain into every container build. Local dev still installs it because it's inrequire-dev. -
.env.examplegains Passport key + Turnstile placeholders. Two commented-outPASSPORT_PRIVATE_KEY/PASSPORT_PUBLIC_KEYstubs document the env-based key-loading path (the recommended alternative to committingstorage/oauth-*.key), and an uncommentedTURNSTILE_ENABLED=false+ empty site/secret keys make the Turnstile middleware a no-op on fresh installs until the admin turns it on. -
Inertia
appEnv/appDebugshared props no longer leak in production.HandleInertiaRequests::shareused to returnconfig('app.env')+config('app.debug')unconditionally. In production this leaked the environment name and advertised whetherAPP_DEBUGwas on to every authenticated user. Both keys now returnnull/falseunderapp()->environment('production'); non-prod keeps the real value for the dev overlay. -
CORS preflight cache raised from 0 to 7200 seconds.
config/cors.phppreviously shippedmax_age => 0, forcing the browser to re-run a preflight on every mutating request. Withmax_age=7200SPA / mobile clients cache the OPTIONS response for 2 hours.
Fixed
-
useDialog/useImageLightbox— 300 ms timer leak. Both composables started asetTimeoutinclose()to delay DOM removal so the exit animation could play. A rapidopen → close → opensequence could queue two timers, with the trailing one firing after the dialog was re-opened and cancelling the render. A module-level timer ref is now cleared on bothopen()andclose()entry; the timeout body nulls the ref when it fires. -
SkFormdirty-form guard — stops parent prop updates from wiping user input. Thewatch(derivedDefaults, ...)block unconditionally reset the form to the new defaults whenever the parent passed a new object. If the user was halfway through filling a form and the parent polled (e.g. a sibling datatable refresh triggered a shared-state update), their in-progress input was wiped. The watcher now checksinternalForm.isDirty— when the form is dirty, the new values are recorded as defaults (so a subsequentreset()picks them up) but the live form state is preserved. -
SkDatatableURL filters —api.get+Promise.allSettled. The URL-driven filter loader used barefetch(...)+Promise.all, so a single 500 on one filter's options endpoint poisoned the whole filter bar via an unhandled rejection. The loader now uses the sharedapi.get<T>()helper (picks up the Axios defaults + XSRF) andPromise.allSettled, so each filter is independent; a failing endpoint falls back to an empty list with a console warning. Same file flipslet activeMenuItems→const activeMenuItems(the ref was never re-assigned). -
TwoFactorTab.enableTwoFactorawaits the Inertia reload. The original code firedrouter.reload({ only: [...] })without awaiting and immediately moved on toloadQrAndSetupKey(). On a slow connection the QR fetch could race the reload and render a stale screen.router.reloadis now wrapped in a promise that resolves ononFinish. -
ProfileInfoTab/UserForm— dropas anyavatar casts. Two(x as any)?.avatar_urlaccesses were replaced with typed shapes — no behaviour change, but the cast hid a legitimate TypeScript error if the backing type ever dropped theavatar_urlaccessor. -
DashboardController::indexgains an explicit: Responsereturn type. Closes the last Larastanreturn_type_missingfinding under the project's configured level.
Upgrade
composer update lvntr/laravel-starter-kit --with-all-dependencies picks up only the package src/ tier (HSTS preload, stub updates). Every other fix above lives in published / stub-backed files. Follow docs/UPGRADE.md for the full diff-style patch list and smoke-test checklist.
2026-04-20 -v.13.3.3
Patch release — Windows build fix for Builder core imports
Fixed
- Windows production build failed with
Could not load .../FormBuilder/core.FormBuilder,DatatableBuilderandTabBuildereach expose acore/directory whoseindex.tsis imported as@lvntr/components/<Builder>/core. On some Windows setups Vite's resolver skipped the directory→index.tsstep and fell through tovite:load-fallback, which tried to read the directory as a file and raisedENOENT. Fix: a siblingcore.tsbarrel file now re-exports from./core/indexfor each of the three builders, so the import resolves to a real file on every platform. macOS/Linux behaviour is unchanged, and existing subpath imports like/core/builderare untouched. Fixes lvntrdev/laravel-starter-kit#1.
2026-04-19 -v.13.3.2
Patch release — security hardening, user audit events, Logo API envelope, media-delete policy, permission-middleware cache correctness, test bootstrap
A batch of latent bugs uncovered by a full test-suite audit, plus a dedicated security review pass that closed a privilege-escalation path in the admin user flow, stopped the settings screen from leaking SMTP/S3/Turnstile secrets to the frontend, and brought the API auth flow to parity with the web flow (email verification + two-factor challenge). Most of the original bugs only showed up under specific runtimes (Octane/queue workers, fresh clones without site:install) or silently swallowed side-effects (audit log for user writes).
Security
-
Privilege escalation via unvalidated role assignment — admin user flow.
StoreUserRequestandUpdateUserRequestused to validate therolefield withRule::exists('roles', 'name')only, so any user holdingusers.createorusers.updatecould submitrole=system_adminin a raw HTTP request regardless of what the admin UI dropdown offered — instantly granting themselves the super-admin role that bypasses every authorization gate viaGate::before.UpdateUserRequestadditionally had no rank check on the target user, so a lower-ranked actor could edit (or demote) a higher-ranked one. Fix:roleis now validated withRule::in(...)built fromRoleSelectOptionsQuery, the same hierarchy-aware list that feeds the dropdown (sort_order >= actor's min sort_order,system_adminexcluded for non-system_admin actors).UpdateUserRequest::authorize()additionally rejects edits where the target's top-ranked role outranks the actor's. A user holdingusers.*as a direct Spatie permission without any assigned role is treated as the lowest possible rank — they can no longer assign any role or edit anyone other than themselves; the previous(int) null = 0fallback accidentally opened the full role list includingsystem_admin. -
Settings secrets no longer leak to the frontend. The admin Settings page was sending
mail.password,storage.spaces_secret,storage.aws_secretandturnstile.secret_keyin plain text as Inertia props for any user withsettings.read. Even values that lived only in.envleaked out through theconfig()fallback. Fix:SettingsDefaultsQuerynow returnsnullfor every secret field and adds a parallel*_is_set: boolflag. The admin UI renders a••••••••placeholder when a value is set and submits an empty string to keep the current secret; writing a non-empty value replaces it. The newtests/Feature/Admin/Settings/SecretsDisclosureTestasserts the Inertia payload never contains the raw secret string anywhere. -
storage.aws_secretnow stored encrypted at rest.config/settings.phpgainedstorage.aws_secretin itssensitive_keyslist — it previously hadmail.password,storage.spaces_secretandturnstile.secret_keybut not the AWS counterpart, so S3 secrets saved through the UI lived as plaintext in thesettingstable.SettingServiceencrypts every listed key withCrypt::encryptStringon write and decrypts on read. -
check.permissionmiddleware now fails closed in production. The middleware used to allow the request through when a route-derived permission (e.g.users.readforusers.index) was not seeded in the database. In production this silently unprotected any new route whose permission row was forgotten. The middleware now throwsAuthorizationException(403) when running underapp()->environment('production')andLog::warnings the unseeded permission in non-production environments — dev ergonomics preserved, the production foot-gun is closed. -
Test-mail endpoint no longer reflects raw exception details.
SettingsController::testMail()used to flash the SMTP exception message (host / username / TLS details) back to the browser. The message is now written toLog::errorwith class + message context; the user sees a generic "Failed to send test email. Check the server logs for details." — same success/failure signal without the information disclosure. -
API auth — email verification and two-factor parity with the web flow. The API previously handed out an access token immediately on register and on any successful password login, bypassing the same email-verification and 2FA checkpoints the web flow enforces. All three
POST /api/v1/auth/*endpoints were reworked:register— when Fortify'semailVerificationfeature is enabled (the default), no token is issued on registration. The endpoint creates the user, firesIlluminate\Auth\Events\Registered(so Fortify's notification pipeline sends the verification link) and returns{ data: { user, requires_verification: true } }with 201. When the feature is disabled, the previous token-on-register behaviour is kept.login— returns a discriminated payload:{ user, token }— normal success{ requires_verification: true }— credentials are valid but the email is not verified (when the verification feature is on){ requires_two_factor: true, challenge: "<uuid>" }— credentials are valid but the account has confirmed 2FA; a single-use challenge id is issued with a 5-minute cache TTL. No access token is issued yet.
two-factor-challenge— new endpointPOST /api/v1/auth/two-factor-challenge(throttled5/min). Accepts{ challenge, code }for TOTP or{ challenge, recovery_code }. On success it returns{ user, token }. TOTP is verified via Fortify'sTwoFactorAuthenticationProvider; recovery codes are matched withhash_equalsand consumed viareplaceRecoveryCodeso they cannot be reused. Invalid / unknown / expired challenges return 401.
Breaking for API consumers — existing clients that expected
{ user, token }on every 2xx response fromregister/loginmust now branch ondata.requires_verificationanddata.requires_two_factorflags, and complete the challenge at/api/v1/auth/two-factor-challengebefore receiving a token when 2FA is confirmed on the account. Non-2FA, verified users keep seeing the old shape. -
Settings
requiredvalidation now matches the UI secret indicator.UpdateMailSettingsRequestandUpdateTurnstileSettingsRequestpreviously only checked the DB row when deciding whether a secret was "already set"; if the value lived only in.env, the UI's*_is_setflag reportedtrue(becauseSettingsDefaultsQueryfalls back toconfig()) but submitting the form with a blank password / secret_key triggered a confusingrequiredvalidation error. Therequiredbranch now mirrors the query — DB row OR config fallback — so env-backed installations no longer see the spurious error. -
IDOR on admin avatar upload / delete.
POST /users/{user}/avatarandDELETE /users/{user}/avatarresolved to no permission underCheckResourcePermissionbecause the route actionsuploadAvatar/deleteAvatarwere not in the middleware'sACTION_ABILITY_MAP; the middleware returned$next($request)without a permission check.UploadAvatarRequest::authorize()also returnedtrueunconditionally. Any authenticated + email-verified user (includinguserrole with onlydashboard.read) could overwrite or delete any other user's avatar — system admin included. Fix: the action map now containsuploadAvatar => updateanddeleteAvatar => update;UploadAvatarRequest::authorize()delegates toUserPolicy::updatewhen a{user}route param is present (self-upload via Profile route is preserved);SettingsController::deleteAvatarcallsGate::authorize('update', $user)explicitly. -
Admin
UserControllerand APIUserController: rank-hierarchy guard on view / update / delete.GET /users/{user}/data,GET /users/{user}/edit,DELETE /users/{user},PATCH /api/v1/users/{user}andDELETE /api/v1/users/{user}used to rely solely on theusers.read/users.update/users.deletepermission and the (admin-only)UpdateUserRequest::authorize()rank check. A lower-ranked admin holding the permission could still read or delete a higher-ranked user through the data endpoint or the API. Fix:UserPolicy::view / update / deletenow run the samecanManage()rank check used by the admin update request (system_admin bypasses, role-less actors are treated as the lowest rank). Admin and API controllers callGate::authorize('view' / 'update' / 'delete', $user)on every cross-user operation. The adminUpdateUserRequestand the APIUpdateUserRequestboth delegateauthorize()toUserPolicy::updateso the rank check is uniform across flows. -
POST /api-routes/regenerate-docswas reachable by any authenticated user. The route actionregenerateDocswas not in theACTION_ABILITY_MAP, soCheckResourcePermissionreturned$next($request)without a permission check. Any authenticated + verified user could trigger the OpenAPI regeneration (which runs an artisan command server-side). Fix:regenerateDocs => updateadded to the map;api-routes.updateadded toconfig/permission-resources.phpso the seeder creates the permission row. -
SVG uploads blocked on logo + FileManager. Both the admin logo uploader (
SettingsController::uploadLogo) and the FileManager default MIME list acceptedimage/svg+xmland stored the file on thepublicdisk. SVG can embed<script>,onloadand foreignObject JavaScript; when a victim opens the direct/storage/...URL, the script executes in the app origin (stored XSS). Fix: logo validation now pinsmimes:png,jpg,jpeg,webp+dimensions:max_width=4096,max_height=4096.UploadFileRequestkeeps aBLOCKED_MIMESlist (image/svg+xml,image/svg,text/html,application/xhtml+xml) that is stripped from the effective MIME list on every upload, regardless of what is stored infile_manager.accepted_mimes.UpdateFileManagerSettingsRequestrejects those MIME types at settings-save time viaRule::notIn(...)plus a^[a-z0-9.+-]+/[a-z0-9.+-]+$regex. The admin UI pickers (MimePickerField,FileManagerTab,GeneralTablogo input) no longer list SVG.SettingsDefaultsQuery::fileManager()also strips the blocked MIMEs from the stored list before sending the payload to the UI, so older installs whose seed includedimage/svg+xmlno longer see it as a selected option. -
Avatar rule tightened.
UploadAvatarRequest::rules()used to be['required','image','max:2048']— theimagerule allows SVG and does not bound pixel dimensions, leaving the door open for polyglot files and decompression-bomb PNGs. New rule:required | image | mimes:jpg,jpeg,png,webp | max:2048 | dimensions:max_width=4096,max_height=4096. -
media-library.disk_namenow defaults tolocal. The previous default waspublic— if the installer seeder failed, an admin flipped the FileManager disk toggle, or someone deployed without running the seeder, user-uploaded documents landed on a world-readable URL. The default is nowlocalso missing configuration fails closed; the FileManager already streams downloads throughDownloadFileAction, it never needed a public URL path. -
SESSION_ENCRYPT+SESSION_SECURE_COOKIEdefault totrue.config/session.phphad'encrypt' => env('SESSION_ENCRYPT', false)and'secure' => env('SESSION_SECURE_COOKIE')(null default). A deployment that forgot to set either env var would ship plaintext session payloads over an insecure cookie on HTTPS. Both defaults are nowtrue; local dev continues to work because.env.examplealready sets both totrueand Herd serves over HTTPS. -
SecurityHeadersmiddleware now emits a baseline CSP. The middleware already set X-Frame-Options / X-Content-Type-Options / Referrer-Policy / Permissions-Policy / HSTS, but noContent-Security-Policy. With twov-htmlsinks in the codebase (the Fortify 2FA QR SVG and the DataTablecolumn.renderescape hatch) a CSP meaningfully limits blast radius. The header is applied in non-local environments only — Vite HMR in local dev needs the dev-server origin on script/connect/style, which varies per developer, so enforcing a tight CSP there would just block normal work. -
Scramble "Try It" disabled in production.
config/scramble.phpshipped withhide_try_it: falseandtry_it_credentials_policy: 'include', which in production handed any admin withapi-docs.readan in-browser API tester that attached their session cookies to every request. Both values now branch onAPP_ENV === 'production'(hidden +omitin prod, interactive in local/staging). -
Passport access-token TTL shortened, scope catalogue seeded. Access tokens were valid for 15 days, personal access tokens for 6 months. A leaked bearer token stayed usable for weeks. Defaults are now
access_token_minutes=60,refresh_token_days=14,personal_token_days=30; the legacyPASSPORT_TOKEN_DAYS/PASSPORT_PERSONAL_TOKEN_MONTHSenv keys still take precedence when set, so existing installs are not disturbed.config/starter-kit.phpalso ships an opt-in scope catalogue (users.read,users.write,files.read,files.write,admin) soPassport::tokensCan()is pre-wired; attachmiddleware('scope:...')to specific API routes when you are ready to enforce per-scope access. -
API register / login now honour the
turnstilemiddleware. Cloudflare Turnstile was already wired for the browser auth forms viaFortifyServiceProvider+ValidateTurnstile, but the API routes (POST /api/v1/auth/register,POST /api/v1/auth/login) only had thethrottle:5,1limiter. An attacker could automate account registration at five accounts per IP per minute. Both routes now run through the existingturnstilemiddleware alias — when Turnstile is disabled in settings the middleware is a no-op, when it is enabled the API picks up the samecf_turnstile_responseenforcement as the web forms.
Fixed
-
User domain events now fire on Create/Update/Delete.
App\Domain\User\Actions\CreateUserAction,UpdateUserActionandDeleteUserActionpreviously had theirUserCreated::dispatch(...)/UserUpdated::dispatch(...)calls commented out or missing — listeners registered inDomainServiceProvider(e.g. the audit-log listener) never ran for user writes.CreateandUpdatenow dispatch when a change actually occurs (no-op updates do not fireUserUpdated);Deletecaptures the id/email before deletion and dispatchesUserDeletedon success, matching theRole*action pattern. -
Admin
users.showroute returned 500.routes/web/user-route.phpregisteredRoute::resource('users', UserController::class), which implicitly opened aGET /users/{user}route — butUserControllernever had ashow()method, so any hit on that URL threwBadMethodCallException. The resource registration is now scoped with->except(['show']); detail data remains available via the existingGET /users/{user}/dataendpoint used by the admin UI. -
Settings logo endpoints now return the
ApiResponseenvelope.POST /settings/logoandDELETE /settings/logoinApp\Http\Controllers\Admin\SettingsControllerused to return rawresponse()->json([...])/response()->json(status: 204), breaking the "every JSON response carries{ success, status, message, data }" contract that the rest of the admin API follows. Both endpoints now go throughto_api(...). The frontend consumer (GeneralTab.vue) readsjson.data.logo_url, which is unchanged. -
App\Policies\UserPolicygained adeleteability.DELETE /media/{media}callsGate::authorize('delete', $media->model)inMediaUploadController. For a media item owned by aUser, thedeleteability was undefined onUserPolicy(onlyviewandupdateexisted), so the Gate fell through to the default deny and returned 403 — even for the owner deleting their own avatar/file. The newdelete(User $actor, User $user)method mirrorsupdate: self is always allowed, otherwise the actor needs theusers.deletepermission. -
CheckResourcePermissionmiddleware: process-wide cache replaced with request-scoped cache. The permission-existence lookup inside the middleware held its result in astatic $cachedvariable. On long-lived workers (Laravel Octane, queue workers that keep the container warm across jobs) this cache never rebuilt, so newly-created permission rows were invisible until the worker restarted. Worse, inside the test suite the static survived across tests —RefreshDatabasetruncated thepermissionstable between tests but the middleware kept reporting permission names seeded by an earlier test as still existing, producing intermittent 403s on routes that should have been permission-less. The cache is now stored viaapp()->instance('check-permission.cache', ...)— request-scoped in production, test-scoped under the testing container. -
UserFactoryseedstwo_factor_*columns asnullby default. Eloquent strict mode (Model::shouldBeStrict(! isProduction()), set byLvntr\StarterKit\StarterKitServiceProvider) throws "attribute [two_factor_secret] either does not exist or was not retrieved" when code reads those columns on a fresh factory instance (e.g. fromProfileControllerafter$this->actingAs(User::factory()->create())). The factory now writestwo_factor_secret,two_factor_recovery_codesandtwo_factor_confirmed_atas explicitnulls so the in-memory model has all three attributes without a->refresh(). -
CreateUserActionandUpdateUserActionnow wrap the write + role sync in a transaction.User::create(...)followed by->syncRoles(...)was running outside a transaction — ifsyncRolesfailed (connection drop, permission cache invalidation, role-not-found race), the user row persisted with no roles, leaving inconsistent state in the admin list. Both actions now run insideDB::transaction(...); the event dispatch happens after the transaction commits so listeners see a consistent state. -
MoveItemAction::wouldCreateCycleno longer issues one SELECT per ancestor. The method used to walk the folder tree byFileFolder::find($parentId)on every hop, so moving a folder with N ancestors produced N queries. For large trees this was both a perf footgun and a potential route for slow-query DoS. The ancestor map is now loaded once per call (singleSELECT id, parent_id WHERE owner_type=? AND owner_id=?) and the walk happens in memory with a cycle-visited guard. -
Folder create / rename / move now catch unique-constraint violations.
CreateFolderAction,RenameFolderActionandMoveItemActioncheck-then-act against(owner_type, owner_id, parent_id, name)uniqueness. Two concurrent requests could pass the existence check in lockstep and the second one would surface a rawQueryException(500) instead of a validation error. The race window is now closed — each action catches SQL-state23000(or MySQL 1062) and rethrows a localisedLogicException, which the controllers already translate to a 422 with thesk-file-manager.errors.duplicate_foldermessage. The existing pre-check still handlesparent_id=NULL(where the unique index does not enforce uniqueness on MySQL/SQLite because NULL is treated as distinct). -
UserDatatableQuerynow eager-loadsmedia.UserResource::$appendsforces theavatar_urlaccessor, which calls$user->getFirstMedia('avatar'). With the datatable query eager-loading onlyroles, every row triggered a separate media lookup (N+1).mediais now part of the eager load list; per-page rendering drops from1 + nqueries to2. -
RoleController@dataand@editnow use aRoleResourceinstead of spreading$role->toArray(). The spread was cheap to add but violated the project's "responses go through a Resource" convention and would silently broadcast any future sensitive column added to therolestable. The newApp\Http\Resources\Admin\Role\RoleResourcelists the intended fields explicitly (id,name,display_name,group,sort_order,guard_name,seeded_permissions, timestamps, + conditionalpermissionswhen loaded). Frontend payload shape is preserved. -
resources/js/pages/Admin/ApiRoutes/Index.vue: external link now hasrel="noopener noreferrer". The "Open API Docs" anchor usedtarget="_blank"without the usual rel attributes. Consistent with the rest of the project. -
Missing translations for the 2FA disable confirmation dialog.
sk-setting.auth.two_factor_disable_titleandsk-setting.auth.two_factor_disable_warningwere referenced from the Auth settings tab but not defined in either language file. Added for EN and TR.
Added
-
Passport key auto-generation for the API test suite.
tests/Pest.phpnow registers abeforeEachhook scoped totests/Feature/Apithat runspassport:keys --forcewhenstorage/oauth-private.keyis missing. Fresh clones and CI runners no longer needphp artisan site:installbefore the Passport-backed tests (AuthTest,UserTest) can pass — the old behaviour was an opaqueLogicException: Invalid key suppliedfromleague/oauth2-server. -
tests/Feature/Domain/User/UserEventsTest.php. Pins the event-dispatch contract introduced by the fix above — asserts thatUserCreatedfires on create,UserUpdatedfires only when at least one tracked field changes,UserDeletedfires on successful delete, and that the self-deletion guard does not spuriously dispatch. -
Logo upload/delete coverage in
tests/Feature/Admin/SettingsTest.php. Locks theApiResponseenvelope onPOST /settings/logo(200 withdata.logo_url) and the 204 contract onDELETE /settings/logo.
2026-04-18 -v.13.3.0
Feature release — Cloudflare Turnstile, last-login tracking, file preview modals, shipped validation.php, and the sk-* translation namespace
A large release. Several independent additions plus one architectural shift on the translation layer.
Added
-
Cloudflare Turnstile captcha on the auth flows. Login, register and password-reset forms now host a Turnstile widget (
resources/js/components/Auth/TurnstileWidget.vue) and validate the token server-side. Ships with: aturnstilemiddleware alias backed byApp\Http\Middleware\ValidateTurnstile, anApp\Rules\TurnstileRulefor ad-hoc validation,App\Domain\Setting\DTOs\TurnstileSettingsDTO, and a Settings → Turnstile admin tab to manage site key / secret key from the UI. Turn it on/off per installation; widgets short-circuit cleanly when the feature is disabled. -
Last login tracking. A new
App\Listeners\UpdateLastLoginlistener, wired toIlluminate\Auth\Events\Login, writeslast_login_atandlast_login_ipto the user on every successful sign-in. Visible on the user detail page and exposed as a sortable column on the users datatable. -
Inactive user block on login.
App\Providers\FortifyServiceProvidernow rejects the login attempt when the authenticated user's status is notactive, returning a clear error message instead of starting a session. Suspending an account no longer requires deleting it. -
FormBuilder.trans(bool). New fluent method available on every field builder (FB.inputText(),FB.select(),FB.toggleSwitch(), …). Controls whether the label is rendered as a translation key (default,true) or as a pre-resolved raw string (false). Useful when you want to compose a label fromtrans('admin.example')inside the script itself — normally this breaks because the form template would call$t()again on the already-translated text and fall back to the original string. With.trans(false)the template skips the second translation step. Default behaviour is unchanged; existing pages continue to work without any edit.FB.inputText().key('last_name'); // default — label → $t('validation.attributes.last_name') FB.inputText().key('x').label(trans('admin.example')).trans(false); // raw render, no second $t() pass -
In-app file previews (lightbox + modal). Uploaded files — in the file manager and in any
FB.fileUpload()form field — no longer open in a new browser tab when you click the thumbnail or file-name. Images fly up in a fullscreen lightbox (Google-Drive style: blurred black backdrop, ESC to dismiss, name in the top-left). Non-image files (PDF, video, audio, text) open inside a mime-aware dialog that embeds the correct viewer (iframe /<video>/<audio>) and offers a "Download" button in the file manager and an "Open in new tab" escape hatch for unrecognised formats. The lightbox is a single global overlay registered next to<AppDialog />inAdminLayout; the modal is aFilePreviewModalcomponent opened through the existinguseDialogcomposable. -
Categorized mime-type picker in File Manager settings. Settings → File Manager → Accepted file types used to be a long multiselect dropdown. It is now a categorized card-checkbox grid (Images / Documents / Archive) where each option shows the matching file-type icon next to the label. Easier to scan, click target is the whole card, and the list is grouped rather than alphabetical.
-
Feature-toggle cards for "Video uploads" and "Audio uploads". The two toggles in File Manager settings share the same card aesthetic as the mime picker — a tinted icon on the left, a bold label and a short description (e.g. "Allow MP4, WebM, MOV, MKV, AVI and OGG videos.") next to the switch on the right. Clicking anywhere on the card flips the toggle.
-
lang/{en,tr}/validation.phpare now shipped with the kit. Laravel's default rule messages plus theattributesandcustomsections used by both the Laravel validator and by FormBuilder / DatatableBuilder, which auto-resolve a field's label viavalidation.attributes.{key}when.label()is not specified. Turkish messages follow the Laravel-Lang/lang conventions. Consumer apps can edit these files freely to adjust wording or add new attribute labels — no custom translation loader is involved; everything runs through Laravel's native translation system. -
Role name localisation with a graceful fallback chain. The role label shown in the admin topbar / sidebar (shared via Inertia
auth.role) now resolves in three steps: firstroles.display_name[locale]from the database; then the locale key underconfig('permission-resources.display_names.roles.{name}.{locale}'); and finallyStr::headline($role->name)— so a freshly seeded role likesystem_adminshows as "System Admin" instead of the raw slug, even when nothing localised has been configured.
Changed — translations moved to the sk-* namespace
Every shipped translation file now has an sk- filename prefix: sk-admin.php, sk-auth.php, sk-button.php, sk-datatable.php, sk-menu.php, sk-setting.php, sk-user.php, sk-attribute.php, sk-file-manager.php, sk-activity-log.php, … All shipped Vue pages and PHP code now reference the new keys (__('sk-button.save') instead of __('button.save')). The goal: consumer apps are free to own the unprefixed namespace (lang/en/admin.php for their own dashboard strings, not a collision with the starter kit's menu items).
Removed
The pre-13.3 unprefixed stubs — stubs/lang/{en,tr}/{admin,auth,button,common,datatable,enums,file-manager,message,pagination,passwords,validation}.php (21 files) — are no longer shipped. No code path in the kit references them after the sk-* migration; keeping them in fresh installs only caused confusion. The package-level starter-kit:: namespace is untouched — __('starter-kit::admin.menu') calls still resolve.
Fixed
-
Upload validation rejected
.oggvideo and.avifiles even with "Video uploads" enabled. Theallow_video=truebranch of the upload request only whitelistedvideo/mp4,video/webm,video/quicktimeandvideo/x-matroska. Addedvideo/ogg,video/x-msvideoandvideo/avi, and added the matching extension labels (.OGV,.AVI) to the "Allowed types" list shown in validation error messages. -
Spurious
npm run buildwarnings silenced. Two noisy warnings have been scrubbed from production builds: (1) the "Sourcemap is likely to be incorrect" notices emitted by@tailwindcss/viteand@inertiajs/vite— both plugins skip sourcemap regeneration after their transform, the runtime is unaffected — are now filtered via a targeted Rolluponwarnhook invite.config.ts(other warnings still pass through); (2) theresolveDirective imported but never usedwarning from the shippedSkDatatable.vueandFileManager.vue— PrimeVue'sv-tooltip/v-rippledirectives are now bound explicitly in the<script setup>block (const vTooltip = Tooltip) so the template compiles to a direct reference instead of a dynamic lookup.
Upgrading from 13.2.x
sk:update is hash-aware: files you have not modified are replaced with the new version; files you have modified are reported as skipped or untracked and left alone. Several 13.3 feature files — SettingsController, SettingsDefaultsQuery, FortifyServiceProvider, HandleInertiaRequests, AppServiceProvider, and the new FormRequest classes — will likely show up in that list and need attention.
-
Run
php artisan sk:update --dry-runto see what is skipped/untracked. -
If you have no local customisations in the
app/layer, take the package version for everything:php artisan sk:update --force -
Pull the new translation files manually (
sk:updatedoes not touchlang/):cp vendor/lvntr/laravel-starter-kit/stubs/lang/en/sk-*.php lang/en/ cp vendor/lvntr/laravel-starter-kit/stubs/lang/tr/sk-*.php lang/tr/ -
If your
lang/en/still containsadmin.php,auth.php, … from a priorsk:install, they now linger as orphans. The package no longer references them; delete them after migrating your__('admin.x')calls to__('sk-admin.x'). -
npm run build— the newTurnstileWidget.vueis shipped and imported byLogin/Register/ForgotPassword. Fresh installs get it automatically. Existing installs missing the file will see the build fail withCould not load resources/js/components/Auth/TurnstileWidget.vue;sk:updateshould copy it (it is a new file, not a replacement), but if not, copy it fromvendor/lvntr/laravel-starter-kit/stubs/resources/js/components/Auth/TurnstileWidget.vue.
2026-04-16 -v.13.2.9
npm run build — lang JSON dual-import warning eliminated
Consumer projects were emitting two warnings on every npm run build:
(!) lang/php_en.json is dynamically imported by resources/js/app.ts but also statically imported by resources/js/app.ts, dynamic import will not move module into another chunk.
(!) lang/php_tr.json is dynamically imported ...
Cause: the i18nVue resolve callback in resources/js/app.ts held two separate import.meta.glob('../../lang/*.json', ...) calls for SSR and client — one with eager: true (static) and one without (dynamic). Vite analysed both branches statically, saw the same files imported in both static and dynamic form, and warned that the dynamic branch would not get its own chunk. The dynamic branch never produced any benefit because the files were already in the static bundle.
Collapsed to a single eager glob hoisted to the module scope, with a Promise.resolve() wrapper for the client branch:
const langs = import.meta.glob<Record<string, string>>('../../lang/*.json', { eager: true });
const resolveLang = (lang: string) => langs[`../../lang/php_${lang}.json`];
app.use(i18nVue, {
resolve: ssr ? resolveLang : (lang: string) => Promise.resolve(resolveLang(lang)),
});
Lang JSON files are small (a few KB), so static bundling has negligible bundle-size impact — the warning is gone permanently while behaviour is unchanged.
2026-04-16 -v.13.2.8
Cleaner fresh installs
Fresh installs no longer include leftover development-only files or noisy placeholder data.
.env.examplecleanup — duplicateDB_*entries and an old sample database name were removed. The file now keeps only generic placeholders such asyour_databaseandyour_username.- Frontend/install cleanup — unnecessary development-only frontend tooling entries were removed so
npm installstarts from a cleaner baseline. - Less clutter in new projects — stray assistant/tooling files that did not belong in a fresh application are no longer shipped.
2026-04-15 -v.13.2.7
File manager upload — crypto.randomUUID fallback for HTTP contexts
The file manager upload composable generated a temporary id per queued file via crypto.randomUUID(). That API is only defined in a secure context — HTTPS or localhost — so any consumer running on a plain-HTTP dev domain (Herd's .test, a bare intranet IP, etc.) hit TypeError: crypto.randomUUID is not a function and the upload aborted before the first XHR fired.
useFileManager now routes through a local generateTempId() helper with a three-tier fallback:
crypto.randomUUID()when available (HTTPS / localhost)crypto.getRandomValues(new Uint8Array(16))serialized as hex (available in every modern browser, no secure-context requirement)Date.now().toString(16)+Math.random().toString(16)as last-resort
The tempId is only used to correlate a pending-upload row with its completion/error callback — no cryptographic strength needed, so the fallback is safe.
Security headers — geolocation permitted from own origin
SecurityHeaders middleware Permissions-Policy was geolocation=() (fully denied). Changed to geolocation=(self) so first-party scripts can request geolocation when a feature legitimately needs it; third-party frames remain blocked.
2026-04-15 -v.13.2.6
File manager validation messages — readable, localised, with original filename
Server-side rejection toasts now actually surface in the file manager UI and carry a friendly message instead of Laravel's raw files.0 field must be a file of type: image/webp text.
- Toast group fix — every
toast.add()call inFileManager.vuenow passesgroup: 'bc'. The sharedToastComponentis mounted withgroup="bc", so previous calls without the key were silently dropped. Folder create/rename/delete/move and file upload toasts (success and error) all surface again. - Server error extraction — the upload XHR previously read only
envelope.message("Validation error.") on a 422. The composable now walksenvelope.errorsand surfaces the first field-specific message, so the toast carries the actual reason. - Per-file friendly validation messages —
UploadFileRequestoverridesattributes()andmessages(). Eachfiles.{i}slot is bound to the file'sgetClientOriginalName()(so the toast saysvacation.jpg yüklenemedi: …instead offiles.0). The mimetypes / max-size errors map to translation keys with a readable extension list (İzinli tipler: WEBP, PDF, JPG, …) and human-friendly size limit (en fazla 10 MB). - Translation keys —
errors.upload_invalid_type,errors.upload_too_large,errors.upload_invalid_fileadded inlang/{en,tr}/file-manager.php.
Two new feature tests cover the friendly messages: it returns a friendly validation message with original filename when mime is rejected and … with size limit when file is too large. The full file manager + install + publish suites stay green (22/22 + 11/11).
Helpers reorganized — vendor-owned core, user-owned custom, publishable override
to_api() and format_date() (plus two new helpers — see below) now ship from the package vendor and are autoloaded automatically. End-user apps no longer keep a to_api copy under app/, removing the merge headache that used to come up every sk:update.
vendor/lvntr/laravel-starter-kit/src/sk-helpers.phpis the canonical location. It is registered via the package'scomposer.jsonautoload.files, so any consumer gets the helpers the moment theycomposer require.app/Helpers/custom.phpis published into the consumer app on first install, added to the app'scomposer.jsonautoload.files, and is never overwritten bysk:update. This is where user-specific global helpers live.app/helpers.phpis deprecated.sk:updatenow compares the existing file's md5 against a list of known stock hashes; if it matches, the file is removed silently. If the user added their own functions, the file is left in place with a console warning so their code is preserved. Thecomposer.jsonautoload entry is rewritten only when the file is actually gone — never silently breaking user code.- Two new helpers —
definition($key, $value)returns the matching definition record (object) fromDefinitionService;definitionLabel($key, $value)returns itslabel. Useful for resolving enum-style values to display strings without re-fetching the definition list per call.
sk:publish --tag=helpers — override the bundled helpers without forking
A new tag exposes sk-helpers.php to the publish command. After publishing, the file lands at app/Helpers/sk-helpers.php and the user can edit it freely.
The vendor file detects the published copy at autoload time and routes through it via require_once:
$skPublishedHelpers = dirname(__DIR__, 4).'/app/Helpers/sk-helpers.php';
if (is_file($skPublishedHelpers) && realpath($skPublishedHelpers) !== realpath(__FILE__)) {
require_once $skPublishedHelpers;
return;
}
The realpath guard prevents self-recursion when the file is loaded as the published copy. No composer.json change is needed — composer autoload still triggers vendor's file, which then delegates to the user's. Deleting the published file reverts to the vendor implementation immediately.
The sk:publish interactive prompt gained a fourth option: Global Helpers (sk-helpers.php).
2026-04-14 -v.13.2.4
Type-safety sweep — zero vue-tsc and ESLint warnings
The starter kit source now passes vue-tsc --noEmit and eslint 'resources/js/**/*.{ts,vue}' with 0 errors / 0 warnings. No behavioural changes — purely type and lint cleanup.
- tsconfig deduplication — type-checking paths were simplified so the same UI sources are no longer scanned twice. This removes the duplicate errors that were confusing local development.
- Vite
Componentsplugin is single-source — thedirsentry was trimmed toresources/js/componentsonly; the package path is gone. The auto-generatedcomponents.d.tsnow references source paths. - SkDatatable filter types widened —
activeFiltersis now typed as a singleFilterValuealias (string | number | Date | (Date | null)[] | null). DatePicker usages switched fromv-modelto:model-value+@update:model-valuewith narrow casts, soselect,select-button,dateanddaterangefilters each operate on their own typed value. - Tag icon / pagination i18n fixes — the
:iconexpression closes off null leakage with?? undefined, and thefrom/to/totalparams passed todatatable.records_infoare nowString(... ?? 0)to match the expectedstringi18n argument type. SharedPagePropsindex signature —[key: string]: unknownadded so the interface satisfies Inertia'sPagePropsconstraint.useCan()now compiles cleanly underusePage<SharedPageProps>().env.d.tsauth shape aligned with runtime — InertiasharedPageProps.authnow carries{ user, role, role_names, permissions }; AdminHeader'spage.props.auth?.roleand similar reads resolve against correct types.appEnv,appDebug,locale,availableLocalesare also typed on the shared props.- Small prop / cast fixes —
RoleForm.vuecalls Wayfinder asupdate.url({ id })(narrowing the optionalid),Settings/Index.vueaddslogo_url: string | nullto thegeneraltype,Dashboard/Index.vuegreets by the real field (user?.first_nameinstead of a non-existentuser?.name), and the redundantpreserveScroll: trueoption was dropped fromrouter.reload()calls — Inertia v3 already preserves scroll and state onreload()by default. - ESLint warnings — the
v-htmlusage insideSkDatatableis marked with a reasonedeslint-disable-next-line(the render string is author-defined andescapeHtmlis provided).Breadcrumb.rootLabel,FileGrid.emptyLabelandSkTag.{value,icon,color,severity}now havewithDefaultsfallbacks.
No action required for existing installs — changes are purely type/lint level.
2026-04-14 -v.13.2.3
Installer DX — AST-based injection, bootstrap helper, preset-aware guidance
A round of installer and upgrade ergonomics that make composer require lvntr/laravel-starter-kit on a fresh Laravel safer and less invasive.
- AST-based config injection —
sk:installnow editsconfig/app.php,config/filesystems.phpandconfig/media-library.phpvianikic/php-parserwith format-preserving pretty printing. Regex-based patching is gone; the injection is tolerant of different Laravel config formats and fully idempotent (re-runningsk:installis a no-op once injected). - Bootstrap helper cleanup — middleware and exception wiring now flows through a single shared bootstrap helper, making installation updates more predictable.
bootstrap/app.phpis no longer overwritten — the stub copy is removed. Instead the installer AST-injects three lines into the user's existing Laravel default file:api: __DIR__.'/../routes/api.php'insidewithRouting(...), plusBootstrap::middleware(...)/Bootstrap::exceptions(...)calls inside the two closures. User-added middleware, trusted proxies, custom exception reporters etc. are preserved.bootstrap/providers.phpis no longer overwritten — the installer appendsDomainServiceProvider,FortifyServiceProvider,SettingsServiceProviderto the array (idempotent, skips any already registered), leaving the user's existing entries untouched.package.jsonJSON-merge — instead of blind overwrite, the installer merges: stub versions win for shared dependencies, but any user-added deps, scripts, workspaces or root-level keys survive.- First-install detection for lang files —
lang/*is still preserved on re-install (so customisations aren't lost), but on a genuine first install (no hash registry) the installer now force-copies lang stubs so fresh projects don't inherit sparse Laravel defaults while the starter kit UI expects richer keys. - Dead code removed — old
IdentityTypeandYesNoenums that are no longer part of the active flow were removed from fresh installs and update paths. - IdeHelper cleanup —
AppServiceProviderno longer carries an unnecessaryclass_exists(IdeHelperServiceProvider::class)guard in fresh installs. - Explicit
nikic/php-parser ^5.0requirement — was transitively available via Tinker, now a direct package dep. - "Bare Laravel" install guidance — README (EN/TR) and install.md / install.tr.md open with a warning: do not run
install:inertia,install:api, Breeze, Jetstream or similar presets before installing the starter kit — they scaffold controllers, routes, pages and layouts that this kit also ships, and the installer cannot detect them, leaving orphan dead code. - Tests — 12 new
InstallCommandTestcases cover AST config injection (all three files), idempotency, format/comment preservation,package.jsonmerge, first-install detection, bootstrap app/providers AST injection with user-code preservation. Total installer-related suite 20/20 green.
No action required for existing installs — all installer-side changes are backwards-compatible and gated on first-install detection or idempotency guards.
2026-04-14 -v.13.2.2
FileManager — pluggable contexts via ContextRegistry
The FileManager is no longer limited to user / global. Any Eloquent model can own a folder tree with zero service-provider wiring.
- New
ContextRegistryservice (app/Domain/FileManager/Support/) resolves a context key in three steps: explicitregister()→ Laravel morph-map alias →App\Models\{Studly(key)}convention fallback. Unknown keys still return 422 via validation. - Zero-config custom contexts — a model class + a matching policy (
view/update) is all that's needed:<FileManager context="vehicle" :context-id="vehicle.id" height="100%" /> globalbaked into the registry — the old registration inAppServiceProvider::boot()moved intoContextRegistry's constructor, so adopting the starter kit no longer requires any boot-time setup for FM.AppServiceProvideronly binds the singleton now.useris fully auto-resolved — via theApp\Models\Userconvention plus the new shippedapp/Policies/UserPolicy.php(self-access +users.read/users.updateadmin gate). The built-in registrations foruserwere removed in favour of policy-driven auth.- Default authorizer with self-match short-circuit — auto-resolved contexts automatically allow an actor to manage their own record (actor IS owner). Other requests delegate to Laravel policies:
can('view', $owner)for reads,can('update', $owner)for writes. - MorphMap-aware storage —
FileManagerContextDTOnow storesownerTypevia$owner->getMorphClass()so queries and path generation work even when the model has a morph-map alias. - Runtime-driven validation —
FileManagerRequestreplaced the hard-codedin:user,globalrule with a closure that queriesContextRegistryat runtime. Adding a new context no longer touches any Request file;context_idis only required when the registered path contains{id}. - Frontend type loosens for custom keys —
FileManagerContextis now'user' | 'global' | (string & {}), so calling<FileManager context="vehicle" />stays fully type-checked without losing autocomplete on the built-ins. - Upload resilience —
UploadFileRequestnow falls back to a sensible MIME list (image / pdf / office / text) whenfile_manager.accepted_mimesisn't seeded yet, so a fresh install never hits the "file must be of type: ." 422. - Tests — new
CustomContextTestexercises explicit registration, path override, folder listings, unknown-context rejection and morph-map auto-resolution. Total 26/26 FileManager tests green. - Docs — file-manager.md picked up a "Custom contexts" chapter with resolution order, zero-config walkthrough,
VehiclePolicyexample, contract table and override guidance.
2026-04-14 -v.13.2.1
FileManager — UX polish & follow-ups
A batch of refinements landed on top of the initial 13.2.0 release, driven by real usage:
- Preview modal — tile click (for files) or context Open now opens a 90vw modal with inline preview for images, PDF, video, audio and text; non-previewable types fall back to an "Open in new tab" + "Download" pair.
- Per-tile upload progress — uploads now stream per-file via XHR with a progress bar drawn on an optimistic placeholder tile; failed uploads show a dismissable error tile while successful ones slot in when the list refreshes. The toolbar Upload button also spins during the batch.
- Drag-and-drop move — tiles are
draggable; dropping onto a folder tile moves the whole selection there. External file-drag is detected via theFilesdata-transfer type, so internal drags no longer accidentally trigger the upload overlay. - Move modal with folder tree — new Move action in both folder & file context menus opens a dialog with a
FolderTreepicker. Works for both single and bulk selections. - Busy overlay (modal card) — Delete / Move / Rename operations now paint a white modal card over the FileManager area with a spinner, title, description and — for bulk ops — a live "N items remaining" counter plus a Stop button that cancels the remaining iterations.
- Always-visible selection checkbox — each folder / file tile has a top-right checkbox (primary-filled when selected, outline-on-hover when not). Plain click on folders just selects; double-click opens. File tiles still single-click-to-preview. The old 3-dot menus on tiles are gone — right-click is the single entry point.
- Right-click no longer forces selection — opening the context menu on an unselected tile keeps the existing selection untouched; bulk operations apply only when the right-clicked item is already in the selection.
- Keyboard shortcuts —
Ctrl/Cmd + Aselects all items in the current folder,Delete/Backspacedeletes the selection (with confirmation),Escclears the selection. All shortcuts are guarded so they never fire while typing in an input or with a dialog open. - Breadcrumb redesign — replaced the PrimeVue breadcrumb with chip/pill crumbs, separated by chevrons, and moved below the info bar. Long folder names are truncated with
…(configurablemaxChars, default 18). Full path stays accessible as atitletooltip. - Current-folder header + back button — removed the left folder tree (still used inside the Move picker). The main area now shows the current folder name with an icon, plus a
←button when not at the root. - Empty-folder illustration — empty folders render a large outlined folder SVG, a heading, and two-line hints (
Upload/New Folder), replacing the plain "This folder is empty" line. - Aggregate info-bar stats — the file count + total size shown in the info bar now walks the entire subtree of the current folder, not just its immediate files.
- Download across disks —
DownloadFileActionnow usesStorage::disk($media->disk)->download(...)so the force-download route works for local, S3 and DigitalOcean Spaces alike. - Context menu restyle — white rounded card, larger item padding, separator before destructive Delete in both folder and file menus.
- Sort direction tooltip — the asc/desc toggle now has a dynamic PrimeVue tooltip ("Ascending · click for descending" / TR equivalent). As a side effect, the
Tooltipdirective is now globally registered inapp.ts. - Footer credit —
AdminFootershows Crafted with Lvntr Starter Kit linking to lvntr.dev.
See file-manager.md for the refreshed usage guide, props and composable exports.
2026-04-14 -v.13.2.0
FileManager — file management module
A new FileManager module shipped: a Windows Explorer-style UI delivering full file management for user-scoped or global files.
- Nested folders — create, rename, move, cascade delete
- Multi-file upload — drag & drop or button
- Selection — single click,
Ctrl/Cmd + click, rubber-band drag for bulk - Bulk delete — toolbar button or right-click on selected items
- Sort — by name / size / date + asc/desc
- Type-aware previews — image thumbnails + color-coded icons for PDF/Word/Excel/Video/Audio/Archive
- Info bar — current folder file count and aggregate size
- Context menus — separate actions for folder / file / empty area (New Folder, Upload, Select All, Refresh)
Added pages: Files in the main sidebar, Files tab on Admin > Users > Edit. Max upload size, accepted MIME types and video/audio toggles are configurable under Admin > Settings > File Manager.
Storage: user/{id}/files/{uuid}/... and global/files/{uuid}/... — folder moves are metadata-only, files never move on disk.
See file-manager.md for usage and API details.
2026-04-13 -v.13.1.10
FormBuilder — stale form reset fix
Fixed a bug where FB-generated forms could silently reset to stale remote data after an Inertia back() navigation or any page.props refresh that caused formConfig to be recomputed. The internal SkForm now shallow-compares the new derived defaults against the previous ones and skips the reset when the values are identical, preserving in-progress user edits.
Affected: any form built with FB whose config depends on page.props (e.g. conditional isFieldsLocked, isSelf, auth-based field visibility). No API change — existing forms benefit automatically.
2026-04-13 -v.13.1.8
FormBuilder — ColorSelector output format
FB.colorSelector() now supports configurable output formats via .format() and .defaultTone():
format('name')(default) → stores"blue"format('name-tone')→ stores"blue-500"format('hex')→ stores"#3b82f6"
A clickable tone selector is rendered below the dropdown for 'name-tone' and 'hex' formats, with the resolved value shown next to the tone pills. When the initial model value is a hex string, the component reverse-looks it up against the Tailwind palette to restore the matching color + tone.
See formbuilder.md for details.