Repository navigation
v2.13.0
·
128 commits
to main
since this release
Immutable
release. Only release title and notes can be modified.
Added
- Content types have singular and plural labels (#360) — content types reused the single
nameeverywhere, so admin UIs could only say "Add New Products".content_typesgains nullablesingular_name,plural_nameandlabels(JSON) columns, accepted byContentTypeRequestandContentTypeManager::register().ContentType::getLabels()returns a WordPress-style label set (add_new_item,edit_item,all_items,not_found, …) derived from the singular and plural labels. A non-empty string underlabelsoverrides its key, and extra keys pass through.getLabel(),getSingularLabel()andgetPluralLabel()read single values.ContentTypeResourceaddssingular_name,plural_name,labelsand the fully resolvedresolved_labels, andcontent-types.d.tsaddsContentTypeLabels. When unset, the singular falls back toStr::singular( name )and the plural toStr::plural( singular_name ?? name ).nameis conventionally plural ("Pages", "Blog Posts"), and the inflector leaves a word already in the requested form unchanged, so plural and singular names both resolve. The new migration backfills existing rows with the same rules; runphp artisan migrateafter upgrading. The built-in Posts and Pages types declare their labels explicitly. Viewport Width:header on theme patterns (visual-editor#832) —PatternFileParserreads WordPress'sviewportWidthequivalent: the width a preview lays a pattern out at before scaling it down. An optionalpxsuffix is accepted, valid values are clamped to 320–2560, and a missing or invalid value isnull(use the client default). It is exposed asviewport_widthon the block-patterns API and inPatternResolver::toFilterMap()entries, and asResolvedPattern::$viewportWidth(alwaysnullfor user patterns).- Plugin uninstall entry point that runs even when the plugin is inactive (#358) — before this, the only way for a plugin to clean up on delete was to listen to
ap.cmsFramework.plugin.deleting. That hook only reaches plugins whose service provider booted in the same request. The usual flow is to deactivate and then delete later, so the listener never ran and data the plugin kept outside its tables (cached tokens, files instorage/, settings rows) was left behind.plugin.jsonnow accepts an optionaluninstallkey naming an invokable class, e.g."uninstall": "HelloWorld\\Uninstall".PluginManager::delete()registers the plugin's PSR-4 autoloader, resolves the class from the container and invokes it with thePluginmodel. The call comes after thedeletinghook and before the opt-in migration rollback, permission removal and file deletion. It runs whether or not the plugin was active and whether or not$deleteFilesis set. A failing, missing or non-invokable class is logged and never blocks the delete. Manifest validation requires a namespaced class under one of the plugin's ownautoload.psr-4namespaces, so a manifest cannot point the uninstaller at a host or vendor class.delete()re-checks this for rows saved before the key existed. It also refuses a class whose file sits outside the plugin directory, checked before the file is included and again after the class loads, so a PSR-4 prefix that overlaps a host namespace (App\) or a PSR-4 path that climbs out with..cannot reach host code. The PSR-4 map is restored afterwards.Plugin::$uninstallerexposes the declared class.
Changed
PluginManager::clearFrameworkCaches()is public (#359) soUpdateManagercan share it.- Plugin manifests must declare well-formed
autoload.psr-4entries —autoloadandautoload.psr-4must be objects (an explicitnullis refused, not skipped), each namespace must end in a backslash ("HelloWorld\\") and each path must be a relative string inside the plugin (no.., no absolute paths). Composer'sClassLoaderalready threw on a namespace without the trailing backslash, so such a plugin could never activate; it is now refused at install, disk sync and update with a clear validation message instead. - The release workflow imports the docs and changelog on the docs site after each release (#354). The job is skipped when
DOCS_API_TOKENis not set. Its POSTs are not retried, since a retried POST that timed out after being accepted would queue the import twice; re-run the job if an import fails. - The hello-world example uses the
migrations_pathmanifest key and ships anuninstallclass. The plugin authoring guide and the example usedmigrations, a key the framework has never read, so the documented setup silently skipped plugin migrations.
Fixed
- Deleting a plugin reverts all of its migrations, not just the last batch (#355) — with
rollback_migrations_on_deleteset,PluginManager::delete()ranmigrate:rollback --path, which only reverts the last global batch. Once any other migration had run after the plugin's activation, nothing was reverted and the plugin's tables survived. Delete now runsmigrate:resetscoped to the plugin's migrations path, so every migration the plugin ran is reverted whatever its batch. A failed activation still undoes only the batch it just ran. - Plugin migrations run when the plugins directory is a symlink —
PluginManagerresolved the migrations path withrealpath()but passed it tomigrate,migrate:resetandmigrate:rollbackrelative tobase_path(). Whenplugins/(or a parent) is a symlink outsidebase_path(), as on zero-downtime deploys that share it between releases, Laravel looked in the wrong place, found nothing and succeeded silently. Activation skipped the plugin's migrations and delete left its tables. The absolute path is now passed with--realpath. - Plugin updates clear the framework caches (#359) —
UpdateManager::updatePlugin()now runsroute:clear,config:clearandview:clearwhencms.plugins.autoClearFrameworkCachesis on, like activate, deactivate and delete. An update that added or renamed routes otherwise left a staleroute:cachein place. The clear runs after theupdatedhook, so a rolled-back update of an inactive plugin never clears, and is skipped for an active plugin, whose deactivate/activate around the update already cleared. - A plugin update rolls back on any error, not just exceptions —
updatePlugin()caughtExceptiononly, so anError(e.g. aTypeErrorfrom anap.cmsFramework.plugin.updatedlistener or the plugin's provider) left the update half-applied with no restore. It now restores the backup, reverts the row and throwsPluginUpdateException::updateFailed()with the error's message. - A malformed
autoload.psr-4prefix can no longer abort a plugin delete — the uninstaller restored the PSR-4 map with an unguardedClassLoader::setPsr4(), which throws on a namespace without a trailing backslash.delete()then stopped after deactivating the plugin and firingdeleting, leaving a plugin that could never be deleted. Each restore is now guarded and logged, and the uninstaller skips a class whose only matching prefix is malformed. - Restoring an empty PSR-4 prefix no longer wipes the host's fallback directories — an empty (
"") prefix maps to Composer's PSR-4 fallback directories, which the snapshot taken before registering a plugin's autoloader never captured. The restore after an uninstall or a failed activation then replaced the host's fallback list with[]for the rest of the request. The snapshot now records the fallback list for an empty prefix. - Admin pages honour the host's admin middleware and resolve by name under
route:cache(#357) —AdminPageManager::registerRoutes()hard-coded['web', 'auth'], so admin pages registered by plugins could be reached by users who hadn't verified their email or completed two-factor enrolment, while the host's own admin pages required both. The base stack now comes from the newcms.admin.middlewareconfig (default['web', 'auth'], publishable toconfig/cms/admin.phpvia thecms-admin-configtag) and can be adjusted with theap.cmsFramework.admin.middlewarefilter. Each page'scan:{capability}gate is still appended. The config is normalized before the filter runs, so callbacks always receive an array. Only an empty stack falls back toweb+auth; a non-empty stack is used as-is, so keepauthin it. Route names (and the capability middleware) are now set before the HTTP verb, soroute('admin.<slug>')resolves for pages added to a cachedCompiledRouteCollection, whoserefreshNameLookups()is a no-op. - Plugin permissions are seeded and removed even when the RBAC model hasn't loaded yet (#356) —
PluginManager::rbacPermissionModel()looked forArtisanPackUI\RBAC\Models\Permission, but the class is declared asArtisanPackUI\Rbac\Models\Permission. PHP class names ignore case, so the check passed only when something earlier in the request had already loaded the model. Otherwise Composer's case-sensitive autoloader missed it, the method returnednull, and plugin activation and deletion silently skipped the permissions the manifest declared (most often from CLI commands). The locator now references the class with::class.