Skip to content

v2.7.1

Choose a tag to compare

@github-actions github-actions released this 01 Aug 21:00
· 100 commits to main since this release
Immutable release. Only release title and notes can be modified.
861e636

Security

  • Updated guzzlehttp/guzzle to 7.15.2 (from 7.14.0), clearing four advisories: host-only cookie scope not preserved, unbounded response cookies risking denial of service, and Proxy-Authorization headers being sent to origin servers. Guzzle sits directly on the update path — MetadataClient fetches release metadata and checksum sidecars through it, and StreamsDownloadsToDisk fetches the release archive itself — so the proxy-authorization advisory is the one that matters most for hosts behind a corporate proxy. guzzlehttp/psr7 moved 2.12.42.13.0 as a required dependency of that upgrade. No other package changed, and composer.json is untouched, so the lock's content-hash is unaffected.

Added

  • cms.updates.allow_insecure_transport config key — release archives are now downloaded over https only. download_url arrives from the update source's own metadata (for the custom-JSON source, straight out of a remote document) and was previously passed to the downloader with no scheme validation at all. Defaults to false; set CMS_UPDATES_ALLOW_INSECURE_TRANSPORT=true for an air-gapped mirror that genuinely cannot serve https.

  • update:perform --allow-downgrade — see the downgrade-protection entry under Fixed.

  • update:rollback --allow-external — see the rollback-provenance entry under Fixed.

  • CustomFieldManager::RESERVED_FIELD_KEYS and HasCustomFields::flushCustomFieldsRealColumnsCache() — see the custom-field entries under Fixed.

  • php artisan update:status (#256) — reports the most recent performUpdate() run from a persisted step marker: the step it reached, the versions involved, the recorded error, and — for a run that died mid-flight — the outstanding steps with the command to run for each. Exits non-zero when the last run failed or was interrupted, so it composes with health checks. --json emits the raw record for an admin UI; --clear discards it after reporting. Host applications can read the same record programmatically via the new ApplicationUpdateManager::updateState() / clearUpdateState().

  • cms.updates.verify_composer_lock_sync config key (#255) — before invoking composer, the updater compares the on-disk composer.lock's content-hash against a hash computed from composer.json using composer's own algorithm, and aborts with UpdateException::composerFilesOutOfSync naming the real cause when they diverge. Composer's own diagnosis of that state — "This usually happens when composer files are incorrectly merged or the composer.json file is manually edited" — sends the operator hunting for a merge conflict or a hand-edit that never happened. The check fails open: a missing composer.json, a missing or unparseable composer.lock, or a lock without a content-hash is left for composer to adjudicate, so a false alarm can never block an update that would otherwise install cleanly. Defaults to true; set CMS_UPDATES_VERIFY_LOCK_SYNC=false to skip it.

  • cms.updates.state_path and cms.updates.lift_maintenance_on_interrupt config keys (#256) — where the step marker is written (relative paths resolve against storage_path()), and whether the new shutdown guard lifts maintenance mode when an update dies mid-flight. The latter defaults to true; set CMS_UPDATES_LIFT_MAINTENANCE_ON_INTERRUPT=false to fail closed and keep the site down until an operator has verified a possibly half-applied install.

Changed

Deprecated

Removed

Fixed

  • HasCustomFields::applyCustomFieldValues() could write Eloquent's own properties, giving an untrusted custom-field payload an arbitrary-table write primitive — the guard added earlier in this release assigned via $this->{$key} = $value from inside the trait, which is compiled into the model class. In class scope that expression resolves Eloquent's declared protected properties directly and never reaches __set(). A payload of {"table":"users","exists":true,"attributes":{"id":1,"password":"…"}} therefore repointed the model at another table and issued an UPDATE against it on save(). The same assignment from the manager classes, where it lived before, landed harmlessly in the attribute bag — moving it into class scope is what created the hole. Three further bypasses of the same guard are closed with it:

    • Case-variant keys. Schema::getColumnListing() returns canonical-case names and the comparison was strict, but MySQL and SQLite resolve identifiers case-insensitively — so custom_fields[AUTHOR_ID] was "not a real column" to the guard and was the real column to the database. Column, cast, and attribute comparisons are now case-folded on both sides.
    • JSON-path keys. metadata->x matched none of the reserved-key branches, and Model::setAttribute() routes any key containing -> to fillJsonAttribute() — writing the real column, including the very metadata store the guard exists to protect. On a non-JSON column, title->x silently replaced the column with a JSON string. Keys containing -> are now rejected outright.
    • Unregistered keys reaching save(). A key naming neither a reserved attribute nor a registered field became an attribute with no column, so save() raised a QueryException inside the managers' transaction and rolled the whole write back — custom_fields[x]=1 was a one-request DoS on the editor save path.

    applyCustomFieldValues() is now an allowlist: a key is applied only when it names a custom field registered for the model's content type, and values are assigned through setAttribute(), never a dynamic property write. Dropped keys are logged (Log::warning, deduplicated per instance per key) rather than vanishing without a trace. Behaviour for legitimate payloads is unchanged.

  • A custom field could be created with a key that already named a protected column, permanently converting that column into a payload-writable fieldaddColumnToTable() silently returns when the column already exists, and validation constrained only the key's character class and its uniqueness within custom_fields. So a user with customFields.manage creating a field named author_id caused the existing column to be adopted rather than created, and from then on any content editor's custom_fields[author_id] wrote it through the legitimate DB-persisted-field exemption — a quiet escalation from "manage custom fields" to "reassign authorship or change the status of any post". CustomFieldManager::createField() now throws InvalidArgumentException, and CustomFieldRequest adds a matching field-level validation error, when the key already names a column on a target content type's table or appears in the new CustomFieldManager::RESERVED_FIELD_KEYS list.

  • An unknown attribute access ran one query per model instance — the custom-field list was memoized on the model instance, so foreach ( $posts as $post ) { $post->plugin_note; } over 50 posts issued 50 queries, and bootHasCustomFields() flushes that memo on every saved/deleted, so a bulk import re-queried on every iteration. The memo now lives on the container-bound CustomFieldManager singleton, keyed by content type and invalidated by createField() / updateField() / deleteField() / registerField().

  • A column-storage field's default_value was substituted whenever the value read null — so a value an editor had deliberately cleared read back as the default, and a read-then-save round trip resurrected it. The default now applies only when the attribute is genuinely absent, matching what the metadata branch already did via array_key_exists().

  • Dropped custom-field payload keys and inert field registrations produced no signal at all — a plugin author saw only "my field doesn't save", and an operator had no way to notice someone probing custom_fields[author_id]. Dropped keys now emit a deduplicated Log::warning, and registering a field under a reserved key warns at registration time.

  • Several smaller updater defectsfclose() failures went unchecked alongside the short-write fix; a ZIP entry whose statIndex() failed was skipped silently, leaving a stale file behind while the update reported success; entry content was fetched by name while metadata was read by index, so duplicate entry names paired the wrong content with the wrong permissions; archive-supplied permissions are now clamped with & ~0022 so a 0777 .php file cannot land under the docroot writable by a neighbouring tenant; the state store's temp file used a predictable getmypid()-derived name written through File::put() (which follows symlinks) and is now randomized, created with x, and chmod 0600; backup directories are created 0700 rather than 0755; UpdateStateStore::merge() no longer silently discards the whole record when a read fails; state_path now recognises Windows absolute paths; UpdateChecker caches a primitive array rather than a serialized UpdateInfo, removing an object-injection sink on a shared cache; ApplicationUpdateManager is bound as a singleton so the shutdown guard is registered once rather than leaking a closure per resolution; ZIP entry names are stripped of control characters before reaching log context; UpdateStep::recoveryCommand() now reflects the configured composer command instead of a bare composer install that cannot resolve on a Herd/FPM host; and operator guidance that hardcoded storage/backups/application/ now names the configured backup_path.

  • update:status recovery advice was wrong for the earliest steps — a death at steps 1-2 leaves the application tree untouched, but the command told the operator to restore the pre-update snapshot: at step 1 no snapshot exists, and at step 2 a death mid-backup can leave a truncated backup-*.zip that this advice invited them to extract over a healthy tree. Those steps now say to run php artisan up and retry, and warn about a possible partial archive.

  • The cached real-column listing was never invalidated, so schema changes went unnoticed for the life of the process — nothing in CustomFieldManager flushed the cache it invalidates by mutating the table. After a field's column was removed, every payload key naming it stayed silently dropped, and a field re-registered under that key as metadata storage stayed dead; under Octane or a long-lived queue worker "the process" spans many requests. The listing now lives in the new table-keyed CustomFieldColumnCache, flushed by addColumnToTable() and removeColumnFromTable() and exposed as HasCustomFields::flushCustomFieldsRealColumnsCache() for hosts that alter these tables themselves. It previously lived in a protected static property on the trait, which PHP duplicates into every using class — so nothing outside the model could have flushed it.

  • Nothing prevented two updates running at once — a double-clicked admin button, or the scheduled auto_update racing an operator's update:perform. Both would put the site down, both would extract over base_path(), and both would run composer install in the same directory; the interleaved writes produce a tree that no rollback repairs, because the second run's backup snapshots the first run's half-extracted state. Run A's step-10 up also undid run B's step-1 down, serving traffic mid-extraction. performUpdate() now takes an exclusive flock on a sentinel beside the state file (not a cache lock — step 8 runs cache:clear) and additionally refuses to start when the persisted record says in_progress and the PID it recorded is still alive. A stale marker from a kill -9'd run does not wedge the updater.

  • update:status reported a failed rollback as a successful oneUpdateRunStatus::Failed's label asserted Failed (rolled back) unconditionally, and the state file was never updated when the rollback itself threw. So the operator facing the single most dangerous state this updater can produce was told the tree had been restored. The same label lied when backup_enabled = false and when the failure landed before createBackup() ran, in which case no rollback was attempted at all. The label is now plain Failed, and a separate rolled_back field records true / false / null (not attempted), which update:status renders explicitly.

  • The shutdown guard overwrote a terminal Failed record with Interrupted, destroying the real errorperformUpdate()'s catch marks Failed with the actual message and then calls handleUpdateFailure(); if lifting maintenance mode threw in there, the active flag stayed set and the guard fired at shutdown even though the process never died and the error had been caught and handled. It re-stamped the record with a generic "the update process terminated before completing" and printed a resume checklist for a tree that had already been rolled back. The guard now leaves an already-terminal record alone — it still lifts maintenance mode, it just no longer rewrites history.

  • A failure at steps 8-10 rolled back a fully-applied update while leaving migrations applied — a cache:clear hiccup at step 8 restored the pre-update snapshot, leaving old code against a new schema, and reported it as a clean rollback. Rollback is now gated on the failure having happened at or before Migrations; later failures log that the snapshot was deliberately not restored and point at update:status, which already prints the commands to finish forward.

  • The checksum was fetched for the latest release but applied to whatever version was pinned — with --target-version=1.2.3 while latest is 2.0.0, the 1.2.3 archive was compared against 2.0.0's digest. That failed closed on its own, but combined with allow_unverified_updates=true the pinned path installed an arbitrary older archive with no integrity check at all. The digest is now resolved for the target version via the new checksumForVersion() on the GitLab and GitHub sources.

  • There was no downgrade protection anywherehasUpdate() is only ever consulted against latest, never against the requested target, so performUpdate('1.0.0') on a 2.7.1 install was accepted and installed a known-vulnerable older release; migrations are not reversed, so the older code then ran against the newer schema. A target that is not newer than the installed version is now refused unless --allow-downgrade is passed.

  • The GitHub source never populated sha256, so every GitHub-sourced update failed — with the shipped defaults (verify_checksum = true, allow_unverified_updates = false), maybeVerifyChecksum() threw checksumRequired on every update from GitHub, and the only way to make them work at all was CMS_UPDATES_ALLOW_UNVERIFIED=true — which the config itself warns disarms the control mitigating extraction-time vulnerabilities. A fail-closed default whose only workaround is permanently insecure is worse than an insecure default, because the operator makes the change deliberately and never revisits it. GitHubUpdateSource now discovers checksums the way GitLabUpdateSource does: a *.sha256 release asset first, falling back to a SHA-256: marker in the release body.

  • rollback() restored an arbitrary ZIP with no exclusion filter and no provenance check — unlike extractUpdate(), it never consulted exclude_from_update, so a backup archive carrying .env or vendor/autoload.php replaced the live copies, and runComposerInstall() then executed scripts from the restored composer.json. With no argument, update:rollback picks the newest backup-*.zip by mtime with no ownership check, so any other vulnerability yielding a file write under storage/ let an attacker plant a backup and wait. The exclusion list now applies on restore, and a backup path outside the configured backup directory is refused without --allow-external.

  • The update-source SHA-256 was documented as though it were an authenticity control — it is not. The digest comes from the same origin and trust domain as the archive (a sidecar on the same release, or the same JSON document that supplied download_url), so it protects against truncation, CDN corruption and partial downloads, and against nothing else — not a compromised update server, not a compromised release-editor account, not a plaintext MITM. The config comment now says so. There is still no signature verification in this module.

  • php artisan update:status --json exited 0 for failed and interrupted runs — the --json branch returned success before the exit-code logic ran, so php artisan update:status --json || alert — the natural machine-consumption path, and the one the documentation advertises without qualification — never fired on a dead update, while the identical state without --json exited 1. The exit code is now resolved before the output branch and returned by both modes.

  • composer test fatalled instead of producing a reportphpunit.xml set no memory limit, so the documented invocation died at PHP's 128M default inside nikic/php-parser while Scramble's generator walked the route table for the OpenAPI spec test. phpunit.xml now sets memory_limit=512M.

  • ./-prefixed ZIP entries bypassed exclude_from_update, letting a release archive overwrite .env, vendor/, bootstrap/cache/ and the SQLite database — the exclusion list is matched against entry names as strings, and the zip-slip filter normalized .. but never ./ or //. So an entry named literally ./.env was a different string from .env and missed the list entirely, while realpath( dirname( '/base/./.env' ) ) is just /base, so the containment check waved it through as well. Mixing one such entry in with normal app/… entries also made the first path segments differ, so detectCommonRootPrefix() returned null and the entry survived verbatim. The reachable targets were exactly the ones the operator believes are protected: APP_KEY and DB/mail credentials in .env, vendor/ (which the operator believes is rebuilt from the lock), bootstrap/cache/*.php (compiled config executed on every request, bypassing the glob), and database/database.sqlite. Entry names are now canonicalized — split on /, empty and . segments dropped, any .. segment rejected — before both the exclusion check and the containment check, and prefix detection uses the same canonical form so the two cannot disagree about what an entry is named.

  • isPathExcluded() matched on a bare string prefix, so files whose names merely began with an excluded name were never updatedstr_starts_with( 'storage-helpers.php', 'storage' ) is true, so storage-helpers.php, storage.php, vendors/, .envelope.json and friends were skipped by both the backup and the extraction: neither snapshotted nor ever updated, silently stale forever. Matching is now anchored to a path-segment boundary ($path === $exclude || str_starts_with( $path, $exclude . '/' )).

  • Artisan::call( 'down' ) / call( 'up' ) exit codes were ignored, so the maintenance-mode flag could lie in both directions — Laravel's UpCommand::handle() and DownCommand::handle() both wrap their bodies in a try { … } catch { …; return 1; }, so a genuine failure — permission denied unlinking storage/framework/down, an unwritable storage/framework/ — arrives as a non-zero exit code and never as an exception. The catch ( Throwable ) was effectively dead code and the exit code was never checked. A failing up at step 10 therefore "succeeded": the active flag was cleared, the shutdown guard disarmed, and the run marked Completed — leaving the site serving 503 to every visitor while update:status reported a clean finish, which is precisely the outcome the interruption machinery exists to prevent. Symmetrically, a failing down at step 1 let the update proceed to overwrite application files on a live site. Both calls now check the exit code and throw maintenanceModeFailure.

  • fwrite() short writes were unchecked, so a disk that filled mid-extraction silently truncated PHP files — the return value was discarded and fclose()'s was too, so the write loop completed, the entry counted as extracted, and the update proceeded into composer install and migrations over truncated source. The asymmetry was the giveaway: fread() failure was checked and threw extractionEntryFailed, whose own docblock explains that throwing is what engages the rollback machinery — the write side never engaged it. The copy loop now lives in streamEntryToDisk(), which throws on a short write, on a false write, and on a failed fclose() (buffered data can fail to reach disk at close time, so a clean loop is not on its own proof the file landed intact).

  • Directory entries were created before the containment check ranFile::makeDirectory( …, recursive: true ) executed first and isPathWithinExtractRoot() was consulted afterwards, by which point the directories existed and were never removed. The string-level filter means .. cannot reach this code, so the only escape is a pre-existing symlink inside base_path() pointing outside it — routine in Envoyer/Forge/Deployer layouts where storage, public/uploads or bootstrap/cache are symlinked to shared directories. Impact was directory creation only, since file content was correctly withheld by the check before fopen(). Both checks now run before their makeDirectory() call, validating the nearest existing ancestor. The unreachable continue in the directory branch is gone.

  • Extraction wrote through pre-existing symlinks — the containment check validated the target's parent directory, never the target itself, so fopen( …, 'wb' ) followed an existing symlink and truncated whatever it pointed at, and the @chmod that follows chmod'd the link target. The archive cannot introduce a symlink — every entry is written as a regular file and symlink() is never called — so this was strictly about links already on disk: shared config, a log file, a sibling release directory in a blue/green deploy. Extraction now skips any target that is a symlink or an existing non-regular file.

  • The updater never looked for composer where Laravel Herd puts it, so a Herd-only macOS host could not self-update at all (#254) — phpCandidatePaths() has listed Herd's php first since #225, annotated "so hosts that use Herd for both FPM and CLI stay on a single toolchain". Herd bundles composer in that same bin/ directory, but composerCandidatePaths() never learned about it — the one place the Herd awareness didn't get carried over. On a clean Herd install with no Homebrew composer and no global composer (Herd ships one, so there's no reason to brew install another), all five candidate paths missed, discovery returned null, and the updater fell through to bare composer install — which PHP-FPM's stripped PATH (/usr/bin:/bin:/usr/sbin:/sbin) cannot resolve:

    Update failed: Rollback failed: Composer install failed. Output: sh: composer: command not found .
    Original update error: Composer install failed. Output: sh: composer: command not found .
    Manual intervention required. The pre-update snapshot was restored.
    

    Three changes:

    • ~/Library/Application Support/Herd/bin/composer now leads the composer candidate list, mirroring the PHP list's ordering and rationale. Herd's composer is a #!/usr/bin/env php script rather than a standalone binary, which needed no other changes: buildComposerCommand() already invokes the discovered path as {CLI PHP} {binary} install ....

    • Both candidate lists now derive Herd's bin/ directory from a single herdBinPath() helper, so they cannot drift apart again the way they did here.

    • A failed rollback --version probe against a path PHP also cannot stat() now throws the new UpdateException::configuredComposerBinaryMissing, naming the offending path. Such a path can only have come from COMPOSER_BINARY / cms.updates.composer_binary, since discovery only ever returns a path it has already stat'd. This closes a second, more confusing failure: because /opt/homebrew/bin/composer is advertised in the composerBinaryNotFound message and is the canonical macOS location, the natural next move on a Herd-only machine was to set COMPOSER_BINARY to it — producing "Composer binary was located but could not be executed … Could not open input file" closing with a CMS_PHP_BINARY hint. It was never located, only configured; and resolvePhpBinary() had done its job correctly, so the trailing hint blamed the one component that was already right.

      The stat runs after the probe rather than gating it, which matters for #233: a path PHP cannot stat() may still be perfectly reachable by the shelled-out child under PHP-FPM sandboxing, and pointing COMPOSER_BINARY at such a path is the documented workaround for that case. A pre-flight is_file() gate would have closed that escape hatch. Probes that succeed are still honoured regardless of what is_file() thinks.

    Hosts on an older framework version can work around this by setting the full path in .env, quoted because it contains spaces: COMPOSER_BINARY="/Users/{you}/Library/Application Support/Herd/bin/composer".

  • The updater excluded composer.lock from extraction but ran composer install, breaking every release that changed a dependency constraint (#255) — composer.lock sat in the exclude_from_update default annotated // Rebuilt via composer install. It isn't: composer install only ever reads a lock file and aborts when it disagrees with composer.json; only composer update / composer require write one. The identical comment on the neighbouring vendor entry is correct, which made the pair easy to read past. The consequence was that extractUpdate() skipped the release's lock — leaving the old one in place — while composer.json was not excluded and was overwritten with the new one, then handed that mismatched pair to composer:

    Update failed: Composer install failed. Output: Installing dependencies from lock file
      - Required package "artisanpack-ui/cms-framework" is in the lock file as "2.5.4" but that
        does not satisfy your constraint "^2.7.0". This usually happens when composer files are
        incorrectly merged or the composer.json file is manually edited.
    

    This was not an edge case: it failed every release that changed any dependency constraint, on every host, and was unaffected by the COMPOSER_BINARY / CMS_PHP_BINARY workarounds accumulated in #225, #232, #233 and #254 — those get composer running; this failure happens after composer is running correctly. Releases that changed no dependencies still installed, which is why it went unnoticed until the first constraint bump, at which point it broke every installation simultaneously. Three changes:

    • composer.lock is no longer excluded from extraction, so the release's lock lands beside its composer.json and hosts install the exact dependency set the release was built and tested against — rather than re-resolving the whole tree on production hardware at update time, which would leave every site on a slightly different, untested dependency set depending on when it updated. This makes a committed, in-sync composer.lock a requirement of the release archive; free for the auto_archive strategy, and now documented for release_asset. The pre-update snapshot picks the lock up for the same reason, so a rollback restores the old composer.json and old lock together.
    • A lock-sync pre-flight check aborts with an accurate message when the two files disagree, instead of letting composer's merge-conflict guess reach the operator. See the new cms.updates.verify_composer_lock_sync key above.
    • The misleading // Rebuilt via composer install comment is gone, replaced by a config block explaining why the lock must land. It is presumably what led to the exclusion in the first place.

    Hosts blocked on an older framework version can work around this by overriding exclude_from_update in config/cms/updates.php with composer.lock omitted; mergeConfigFrom() array-merges the package defaults underneath, so a partial override replaces only that key.

  • performUpdate() had no execution-time guard, so a PHP timeout killed it mid-flight and left the site stuck in maintenance mode (#256) — runComposerInstall() gave the composer child a cms.updates.composer_timeout budget (default 600s), but the parent PHP request was still governed by max_execution_time, which defaults to 30 seconds under PHP-FPM — the path the admin UI uses. Composer was given ten minutes and the request was killed after thirty seconds. Worse, an execution-time fatal is raised at shutdown rather than thrown, so performUpdate()'s catch block never ran: no rollback, and step 10's disableMaintenanceMode() never executed. The operator was left with a site returning 503 to every visitor, no error in the UI (the request died before rendering a response), and no automatic way back. Every other failure in this module to date failed safely; this one failed open. Three guards now cover it:

    • performUpdate() and rollback() call set_time_limit( 0 ) and ignore_user_abort( true ) up front, so neither PHP's execution ceiling nor the operator closing the browser tab can kill the request mid-update. Hosts that put set_time_limit in disable_functions get a warning log naming php artisan update:perform as the supported path instead.
    • enableMaintenanceMode() registers a shutdown guard that lifts maintenance mode if the process dies before step 10 — covering out-of-memory fatals and FPM's request_terminate_timeout, neither of which set_time_limit() can override. It logs a critical entry naming the step it died on, and if artisan up itself fails (likely when shutting down after an OOM fatal) it removes storage/framework/down directly.
    • Each of the ten steps is persisted to a state file as it is entered, so a killed update is detectable. Previously there was no way to distinguish "update in progress", "update died at step 6", and "the site was manually put into maintenance mode". A flat file rather than a cache entry on purpose: step 8 runs cache:clear, and the database cache driver is unavailable while step 7's migrations are mid-flight.

    Nothing here makes an HTTP request a good place to run a multi-minute job — php artisan update:perform from the CLI remains the supported path, and moving the HTTP endpoint to a queued job is tracked separately. These guards make the HTTP path fail safely instead of taking the site down and leaving it there.

Security

  • A custom-field value whose key named a real column overwrote that column (#253) — HasCustomFields::findCustomFieldByKey() has always refused to resolve a key that shadows a real DB column, so the metadata write was blocked; what it never blocked was the fall-through. __set() handed the value to parent::__set(), which wrote it straight into the column. BlogManager::applyCustomFields() and PageManager::applyCustomFields() (#250) assigned each payload key that way, and the custom-field half of a request payload gets no fillable filtering — so custom_fields[author_id] on a post update reassigned the author, whatever the attribute allowlist said. A registration wasn't even required: any key naming a column worked.

    New HasCustomFields::applyCustomFieldValues() is the supported way to apply an untrusted custom-field payload. It routes each value through the same magic setter as before, but silently drops keys that name a real column, cast, mutator, accessor, or relation first. Both managers now delegate to it, so create()/update() are covered on Post and Page, and any host content type using the trait gets the same guard for free — downstream apps no longer need to reimplement the check ahead of the manager call.

    DB-registered fields are exempt, because they legitimately own the column they name: createField() adds it, and a persisted row is always column-storage (custom_fields has no storage column, so CustomField::storageMode() resolves an existing row to column). The exemption keys off exists, which CustomFieldManager::filterFieldsForContentType() forces to false on every filter-registered field — so a plugin cannot buy itself a real-column write by declaring storage => 'column', and only a row in the custom_fields table, which takes the custom-field admin capability to create, qualifies.

    The magic setter itself is deliberately unchanged. Guarding __set() would have meant that a plugin registering a field keyed to title could stop host code from writing $post->title at all — a required-column write turned into a save failure by anyone able to register a field. Trusted assignment and untrusted payload application are separate operations, and only the latter is guarded. Nothing legitimate is lost: a shadowing field can never round-trip, because the getter resolves a real column through Eloquent and never consults metadata.