Skip to content

v2.13.0

Choose a tag to compare

@github-actions github-actions released this 11 Oct 00:17
· 128 commits to main since this release
Immutable release. Only release title and notes can be modified.
73e1464

Added

  • Content types have singular and plural labels (#360) — content types reused the single name everywhere, so admin UIs could only say "Add New Products". content_types gains nullable singular_name, plural_name and labels (JSON) columns, accepted by ContentTypeRequest and ContentTypeManager::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 under labels overrides its key, and extra keys pass through. getLabel(), getSingularLabel() and getPluralLabel() read single values. ContentTypeResource adds singular_name, plural_name, labels and the fully resolved resolved_labels, and content-types.d.ts adds ContentTypeLabels. When unset, the singular falls back to Str::singular( name ) and the plural to Str::plural( singular_name ?? name ). name is 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; run php artisan migrate after upgrading. The built-in Posts and Pages types declare their labels explicitly.
  • Viewport Width: header on theme patterns (visual-editor#832) — PatternFileParser reads WordPress's viewportWidth equivalent: the width a preview lays a pattern out at before scaling it down. An optional px suffix is accepted, valid values are clamped to 320–2560, and a missing or invalid value is null (use the client default). It is exposed as viewport_width on the block-patterns API and in PatternResolver::toFilterMap() entries, and as ResolvedPattern::$viewportWidth (always null for 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 in storage/, settings rows) was left behind. plugin.json now accepts an optional uninstall key 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 the Plugin model. The call comes after the deleting hook 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 $deleteFiles is 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 own autoload.psr-4 namespaces, 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::$uninstaller exposes the declared class.

Changed

  • PluginManager::clearFrameworkCaches() is public (#359) so UpdateManager can share it.
  • Plugin manifests must declare well-formed autoload.psr-4 entries — autoload and autoload.psr-4 must be objects (an explicit null is 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's ClassLoader already 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_TOKEN is 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_path manifest key and ships an uninstall class. The plugin authoring guide and the example used migrations, 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_delete set, PluginManager::delete() ran migrate: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 runs migrate:reset scoped 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 — PluginManager resolved the migrations path with realpath() but passed it to migrate, migrate:reset and migrate:rollback relative to base_path(). When plugins/ (or a parent) is a symlink outside base_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 runs route:clear, config:clear and view:clear when cms.plugins.autoClearFrameworkCaches is on, like activate, deactivate and delete. An update that added or renamed routes otherwise left a stale route:cache in place. The clear runs after the updated hook, 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() caught Exception only, so an Error (e.g. a TypeError from an ap.cmsFramework.plugin.updated listener or the plugin's provider) left the update half-applied with no restore. It now restores the backup, reverts the row and throws PluginUpdateException::updateFailed() with the error's message.
  • A malformed autoload.psr-4 prefix can no longer abort a plugin delete — the uninstaller restored the PSR-4 map with an unguarded ClassLoader::setPsr4(), which throws on a namespace without a trailing backslash. delete() then stopped after deactivating the plugin and firing deleting, 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 new cms.admin.middleware config (default ['web', 'auth'], publishable to config/cms/admin.php via the cms-admin-config tag) and can be adjusted with the ap.cmsFramework.admin.middleware filter. Each page's can:{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 to web + auth; a non-empty stack is used as-is, so keep auth in it. Route names (and the capability middleware) are now set before the HTTP verb, so route('admin.<slug>') resolves for pages added to a cached CompiledRouteCollection, whose refreshNameLookups() is a no-op.
  • Plugin permissions are seeded and removed even when the RBAC model hasn't loaded yet (#356) — PluginManager::rbacPermissionModel() looked for ArtisanPackUI\RBAC\Models\Permission, but the class is declared as ArtisanPackUI\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 returned null, 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.