CMS Framework - v2.2.2
Themes - Installing from a ZIP
The Themes module supports uploading and installing themes from a ZIP archive at runtime. Use this for themes acquired from a marketplace, sent over by a designer, or restored from a backup — anything where you don't have direct filesystem access.
Added in 2.0.0.
Programmatic install
use ArtisanPackUI\CMSFramework\Modules\Themes\Managers\ThemeManager;
$themeManager = app(ThemeManager::class);
try {
$manifest = $themeManager->installFromZip('/path/to/uploaded-theme.zip');
// $manifest is the parsed theme.json of the freshly installed theme
} catch (\ArtisanPackUI\CMSFramework\Modules\Themes\Exceptions\ThemeInstallationException $e) {
// ZIP corrupt, extraction failed, or slug already exists
} catch (\ArtisanPackUI\CMSFramework\Modules\Themes\Exceptions\ThemeValidationException $e) {
// Extracted theme failed strict manifest validation
}
REST endpoint
POST /api/v1/themes
Content-Type: multipart/form-data
theme=@/path/to/theme.zip
The endpoint accepts a single multipart file field named theme. On success it returns 201 Created with the parsed manifest. On failure it returns 422 (validation error) or 400 (extraction error) with the framework's standard error envelope.
Pre-extraction validation
Before extracting, ThemeManager::installFromZip():
- Confirms the file exists, is readable, and ends in
.zip. - Confirms the slug derived from the ZIP's root directory name does not collide with an already-installed theme.
- Scans the archive for ZIP-slip attempts (entries whose normalized destination falls outside the themes base directory). Any such entry aborts extraction.
Post-extraction strict validation
After extraction, validateManifest() runs an additional pass that is stricter than the standard discovery validator. This is the contract every uploadable theme must satisfy. It is not applied to themes already on disk so existing installations are not broken by tightened rules.
Required fields:
slug— alphanumeric, hyphens, and underscores only (/^[a-zA-Z0-9_-]+$/).name— non-empty string.version— anchored semverMAJOR.MINOR.PATCH(/^\d+\.\d+\.\d+$/). Anchoring is deliberate; it prevents injection suffixes such as1.0.0'; DROP TABLE.
Optional fields, validated when present:
screenshot— basename only, no path separators (/or\), with an allowlisted image extension (png,jpg,jpeg,webp).requires— anchored semver, same shape asversion.templates.layouts,templates.pages,templates.partials— arrays of strings.supports.*— booleans (e.g.supports.menus,supports.widgets).
If strict validation fails, the freshly extracted theme directory is removed and a ThemeValidationException is thrown — you never end up with a half-installed theme on disk.
WP theme.json schema validation
Themes that carry the WP-shape subset (settings, styles, customTemplates, templateParts, patterns) are additionally validated against the pinned WordPress theme.json schema. Schema failures throw ThemeValidationException::invalidManifest() with the offending key in the message.
The pinned schema version lives at config('cms.themes.wpThemeJsonSchemaVersion') (default '3', matching WordPress 6.8). Bumping it requires also updating the bundled schema file at src/Modules/Themes/Validation/schemas/wp-theme-json-v{N}.json.
Lifecycle hooks
installFromZip() fires two action hooks — see themes/Lifecycle Hooks for details:
| Hook | When |
|---|---|
theme.installing |
After successful extraction and validation, before the install is committed. Listeners may throw to abort; the extracted directory is rolled back. |
theme.installed |
After the install is committed. Listeners may run side-effects (run seeders, send notifications, etc.). |
Limits and cleanup
- ZIP files larger than
php.ini'supload_max_filesizeare rejected by Laravel's form-request validator before reaching the controller. - The temporary extracted directory is removed automatically on validation failure.
- The original ZIP file is not deleted by
installFromZip()— that's the caller's responsibility (a controller wrapping a multipart upload would typically use$request->file('theme')->store('temp')and clean up after success).