v2.9.0
Security
-
The user resource endpoints now enforce authorization, not just authentication —
UserController'sindex/store/show/update/destroycarried no authorization check at all, so any authenticated user could list, create, edit (including resetting another user's password), or delete any user. A newUserPolicy— registered against the host-configured user model and mirroringRolePolicy— now gates all five actions on theusers.managecapability (users.deletefor deletion), the same capabilities the bulk endpoint already enforces. Breaking for API consumers: a plausibly-privileged but ungranted authenticated user now receives403from these endpoints, and a guest401. -
The notification-table rename migration no longer risks renaming Laravel's own
notificationstable — the migration guarded only on table presence, so an install that had worked around the 2.8 collision (create migrations recorded as run,cms_tables absent, Laravel's ownnotificationspresent) would rename Laravel's table intocms_notifications, breaking the CMS model and stranding Laravel's database-channel data. The rename now shape-sniffs the ambiguousnotificationsname and only renames it when it carries the CMS columns (send_emailpresent,notifiable_typeabsent). -
The
CollectionEditorandFieldBuilderLivewire components are hardened against property tampering — theirtypeIdis now#[Locked], so a client cannot rewrite it between requests to operate on a type it was never authorized for.CollectionEditor::save()now authorizes creates with the type-awarecreateForTypegate (matching the REST controller) and re-checksisCollection()and the record/type match;FieldBuilder::save()now authorizes creation with thecreategate (previously onlymount()did, so an edit-mode mount that nulledtypeIdcould create types unauthorized) and validates the slug's uniqueness (create) and immutability (update) the wayDynamicContentTypeRequestdoes. -
Admin pages registered with an empty or missing
capabilitynow fall back to theaccess_admin_dashboardbaseline instead of registering anauth-only route any authenticated user could reach. -
The single-notification endpoint no longer leaks id existence —
GET /api/v1/notifications/{id}returned403for a notification that exists but belongs to another user and404for one that does not, an enumeration oracle. It now returns404in both cases, routing the ownership decision through the registeredviewpolicy. -
Bumped the transitive
league/commonmarkdependency to>=2.10.0, resolving 6 advisories that affected<2.9.0— including a high-severity quadratic-time denial of service when parsing crafted Markdown (CVE-2026-71488) and anunsafe-linkfilter bypass (CVE-2026-71478). Laravel already permits the newer release (^2.8.1), so only the lock file changed. -
MenuItemRequestnow fails closed on themenu/parentexistence rules when no theme is active, instead of widening to an unscopedexists(#291) — with no active theme,menuExistsRule()andparentExistsRule()previously fell back to a bareexists:menus,id/exists:menu_items,id, accepting ids from any theme where the scoped rule would reject them.MenuItemsControllerfailed closed regardless (409 before reading the payload, andthemeScopedQuery()returnsnull), so no cross-theme record was ever read or written; what remained was a weak enumeration oracle — a422(id exists nowhere) was distinguishable from a409(id exists in some theme, but none is active). Both rules now scope to a never-matchingwhereRaw('1 = 0')on a null theme, so the request states the same invariant as the controller and a future refactor that moves the controller guard cannot silently turn the hardening gap into a real cross-theme IDOR. Observable change: posting a menu item while no theme is active now returns422instead of409— both 4xx refusals of the same request; no successful call changes behavior. -
Plugin updates now re-run the full manifest validation, closing the bypass where every install-time security check was skipped for the rest of a plugin's life (#283) —
UpdateManager::updatePlugin()wrote the manifest from the downloaded ZIP straight intoplugins.metawithout re-runningPluginManager::validateManifest(), so an update could seat values that would have been refused at install: amigrations_pathtraversal like../../database/migrations(whichrunMigrations()resolves against the plugin directory and could use to re-run or roll back framework migrations), an unprefixedpermissionsentry likemanage_users(which could wipe framework-owned permission rows on uninstall), or a malformedmin_host_version/federated_module/nav_entries/updatevalue reaching consumers that assume the validated shape. The update path now re-validates the extracted manifest (via the new publicPluginManager::assertManifestValid()) before seating it; a rejected manifest unwinds through the existing backup-restore path — old files and database row restored — and surfaces as aPluginUpdateExceptioncarrying the validation reason. Breaking: a plugin currently shipping a manifest that would fail validation updates fine today and will now fail at update time on the production host. Republish such a plugin with a compliantplugin.jsonbefore updating.
Fixed
NotificationPolicyno longer fatals on a hostUsermodel without the RBAC trait —create()anddelete()called$user->hasCapability()unguarded, and the policy is registered globally, so a plainUsermodel reaching those abilities (via@can,Gate::any, or a direct check) hitCall to undefined method— the same failure class #280 fixed elsewhere. Both methods now guard withmethod_exists()and deny cleanly.Notification::$pivotno longer returns null when the notification was loaded through the inverse relation — the accessor checked the (unloaded)usersrelation and ignored the pivot Eloquent had actually loaded on$user->systemNotifications, so the documented$notification->pivot->is_readpattern read a property on null. It now returns the loaded pivot when present.cms:plugins:syncno longer crashes on a plugin directory whose name is not a valid slug — such a directory raisedPluginNotFoundException, which the sync loop did not catch, so one bad directory aborted the whole command with a stack trace. It is now caught and reported as a singlefailedrow while the remaining plugins sync.- A plugin update that fails to reactivate on a new dependency/conflict is now reported as such, not as a download failure — a new manifest adding an unsatisfiable
requires/conflictsthrewDependencyNotSatisfiedException/PluginConflictExceptionfrom the step-7 reactivation, which fell into the generic catch and surfaced asdownloadFailed. It now rolls back (files, row, andis_active) and surfaces the dependency reason. POST /plugins/{slug}/updatenow returns a structured409when the new version requires a newer host —UpdateManagerrethrowsIncompatiblePluginExceptiondeliberately, but the controller swallowed it into a generic422; it now renders the sameplugin_incompatible409 payloadactivate()does.- Plugin delete/update filesystem paths are built from the trusted database slug rather than the raw route parameter, matching the hardening
getPlugin()already applied. - A plugin ZIP whose
plugin.jsonis missing or unparseable is rejected cleanly —installFromZip()passed a null manifest into a typedarrayparameter (aTypeError) and left the extracted directory orphaned; it now deletes the extraction and throws a clearPluginValidationException. - Boot-time plugin loading catches
Throwable, not justException— a plugin whose service-provider class is missing raised anErrorat boot that took the whole site down; it is now logged and skipped. - The plugin dependency read endpoints are more robust —
check-dependenciesbounds the batch to 100 slugs and reports each slug'sinstalledstatus (an uninstalled slug is no longer silently reported as satisfied);GET {slug}/dependenciesno longer 404s a DB-registered plugin whose files are gone, sourcing its declared dependencies from the graph. - Notification tables no longer collide with Laravel's own database notification storage (#281) — the module claimed the table name
notifications, which is the name Laravel's built-indatabasenotification channel uses, and the two schemas are irreconcilable (Laravel expects a UUID primary key,notifiable_type/notifiable_id, adataJSON column, andread_at; none of which the framework's table has). Any host package reaching for thedatabasechannel failed on insert with aQueryExceptiondeep in the notification pipeline. The three tables now carry acms_prefix —notifications→cms_notifications,notification_user→cms_notification_user,notification_preferences→cms_notification_preferences— leavingnotificationsfree for Laravel. Breaking: a new migration renames the tables in place on existing installs (data is preserved); any application code that queried the old table names directly (rather than through theNotification/NotificationPreferencemodels) must be updated. Fresh installs are unaffected. apSendNotification()/apSendNotificationByRole()no longer fatal when the hostUsermodel lacks the notifications traits (#280) — preference filtering runs on every send and previously callednotificationPreferences()unconditionally, so aUsermodel withoutHasNotificationshit an uncaughtCall to undefined methodeven on a plain send with no roles involved; the by-role helper had the same failure againstroles()withoutHasRolesAndPermissions.NotificationManagernow guards both relationships withmethod_exists(): a model withoutnotificationPreferences()treats every existing recipient as opted in (send succeeds, email path included), and a model withoutroles()matches no users sosendNotificationByRole()returnsnullrather than throwing. The notifications docs now state both trait requirements.- A failed plugin update no longer leaves a previously-active plugin disabled —
UpdateManager::updatePlugin()deactivates before swapping files, but the rollback paths (failed download, extraction, or reactivation) restored the version, manifest, and service provider without restoringis_active. Because the deactivation ran on a separate model instance, the in-memory row'sis_activewas stale andsave()never wrote it back. The revert now refreshes the row and explicitly restores the pre-update activation state. registerAdminPage()'s federatedcomponentflavor now mounts a real admin page instead of 500ing (#296) — the interim fix in #246 stopped the barecomponentidentifier from reachingRoute::get()(an invalid route action that, because admin routes register from abooted()callback, once 500'd every request, public pages included) but left it rendering a chrome-less<div>. Acomponent-only page now renders the framework-ownedcms::admin.layouts.federatedshell — a mount point (<div data-cms-federated-module="…">) inside the admin chrome for the host's Module Federation runtime to hydrate — and a federation host that mounts components its own way overrides the default through the newap.cmsFramework.admin.federatedPageActionfilter (passed the default action, the component identifier, and the page config; a non-closure return falls back to the shipped shell). A page declaring neither aviewnor acomponentstill responds501on its own route rather than breaking route registration.
Added
- Plugin dependency management (#45) — plugins can now declare hard dependencies on, and conflicts with, other plugins via a
requires.pluginsmap and aconflictsmap inplugin.json, each keyed by plugin slug to a semver constraint.PluginManager::activate()gates on them before any state mutation: activation is refused when a required plugin is missing, inactive, or fails its version constraint (DependencyNotSatisfiedException), or when a declared conflict is installed within range (PluginConflictException).deactivate()refuses to disable a plugin while active plugins still depend on it (deactivate( $slug, force: true )bypasses the guard; deletion and in-place updates force past it). Conflicts are enforced symmetrically, so they cannot be bypassed by activation order, and active plugins are loaded dependencies-first at boot so a dependent's service provider never boots before the provider it consumes. New public helperscheckDependencies(),getDependents(),canDeactivate(), andgetActivationOrder()(a dependency-first topological sort that throwsCircularDependencyExceptionon a cycle) back three new read endpoints —GET /api/v1/plugins/{slug}/dependencies,GET /api/v1/plugins/{slug}/dependents, andPOST /api/v1/plugins/check-dependencies. The resolution logic lives in a database-freeDependencyResolverand reusescomposer/semverfor constraint matching. Conflicts are scoped to installed (not merely active) plugins — Composer-style semantics documented indocs/plugin-authoring.md. Motivated by splitting a monolithic Google integration into a reusablegoogle-oauthplugin plus a dependentgoogle-web-toolsplugin. - Disk → database plugin promotion (#298) —
PluginManager::installFromDisk()andsyncFromDisk(), and thecms:plugins:syncartisan command, register plugins that were scaffolded directly on disk (rather than installed from a ZIP) into thepluginstable so they can be activated. Sync re-runs the full manifest validation and the directory/manifest slug-match guard for every discovered plugin, reports a per-plugininstalled/updated/unchanged/failedresult, and continues past a single failure. Console-only — there is no HTTP route. - Blade theme-file fallback for templates and template parts (#126) — the template / template-part resolvers now resolve a
.blade.phptheme file when no block-grammar.htmlfile exists, exposed through newis_blade/editablefields onTemplateResourceandResolvedEntity::toArray(). Blade-backed entities are metadata-only (never read or rendered by the resolver) and read-only in the site editor: a write to a Blade-backed slug is rejected with a422.customTemplatesemits a Blade-shadowed warning. author_nameondiscoverPlugins()/getPlugin()(#297) — a normalized author name string derived from the manifestauthorvalue (plain string or{ name, email?, url? }object). It is shape-normalized, not escaped — escape it on output. Typed inresources/types/plugins.d.ts.- Install-time manifest-slug identity guard (#315) —
installFromZip()andinstallFromDisk()now reject a manifest whoseslugdiffers from the extracted/directory slug, mirroring the update-path guard added under #283. Without it, a plugin could declare — and later, on uninstall, remove — permission rows namespaced under another plugin's slug. Breaking: a plugin ZIP that installed before and carries a mismatched manifest slug now fails at install. - The
ArtisanPackUI.Security.ValidatedSanitizedInputPHPCS sniff is re-enabled (#301) — every request-input read is now either validated/sanitized or carries a reviewedphpcs:ignorewith a rationale, and the Livewire type/collection editors validate their payloads against the same shape as the REST form requests. - TypeScript types for the plugin dependency API —
resources/types/plugins.d.tsgainsPluginManifestRequires,PluginDependencyStatus(withPluginVersionMismatch/PluginConflictEntry), and response interfaces for the three dependency endpoints.
Changed
- User-facing strings in
NotificationControllerand the Blade write-rejection responses are now translatable — the notification action messages (via__()/trans_choice()) and theTemplatesController/TemplatePartsControllerrejectIfBlade()messages are wrapped for i18n. - The generated OpenAPI spec now reports the release version (
2.9.0) — theopenapi.info.versionconfig was refreshed, and its fallback now derives the version from the packagecomposer.jsoninstead of a hardcoded literal that had lagged at2.5.1.