v2.7.1
Security
- Updated
guzzlehttp/guzzleto7.15.2(from7.14.0), clearing four advisories: host-only cookie scope not preserved, unbounded response cookies risking denial of service, andProxy-Authorizationheaders being sent to origin servers. Guzzle sits directly on the update path —MetadataClientfetches release metadata and checksum sidecars through it, andStreamsDownloadsToDiskfetches the release archive itself — so the proxy-authorization advisory is the one that matters most for hosts behind a corporate proxy.guzzlehttp/psr7moved2.12.4→2.13.0as a required dependency of that upgrade. No other package changed, andcomposer.jsonis untouched, so the lock'scontent-hashis unaffected.
Added
-
cms.updates.allow_insecure_transportconfig key — release archives are now downloaded overhttpsonly.download_urlarrives 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 tofalse; setCMS_UPDATES_ALLOW_INSECURE_TRANSPORT=truefor 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_KEYSandHasCustomFields::flushCustomFieldsRealColumnsCache()— see the custom-field entries under Fixed. -
php artisan update:status(#256) — reports the most recentperformUpdate()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.--jsonemits the raw record for an admin UI;--cleardiscards it after reporting. Host applications can read the same record programmatically via the newApplicationUpdateManager::updateState()/clearUpdateState(). -
cms.updates.verify_composer_lock_syncconfig key (#255) — before invoking composer, the updater compares the on-diskcomposer.lock'scontent-hashagainst a hash computed fromcomposer.jsonusing composer's own algorithm, and aborts withUpdateException::composerFilesOutOfSyncnaming 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 missingcomposer.json, a missing or unparseablecomposer.lock, or a lock without acontent-hashis left for composer to adjudicate, so a false alarm can never block an update that would otherwise install cleanly. Defaults totrue; setCMS_UPDATES_VERIFY_LOCK_SYNC=falseto skip it. -
cms.updates.state_pathandcms.updates.lift_maintenance_on_interruptconfig keys (#256) — where the step marker is written (relative paths resolve againststorage_path()), and whether the new shutdown guard lifts maintenance mode when an update dies mid-flight. The latter defaults totrue; setCMS_UPDATES_LIFT_MAINTENANCE_ON_INTERRUPT=falseto 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} = $valuefrom 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 anUPDATEagainst it onsave(). 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 — socustom_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->xmatched none of the reserved-key branches, andModel::setAttribute()routes any key containing->tofillJsonAttribute()— writing the real column, including the verymetadatastore the guard exists to protect. On a non-JSON column,title->xsilently 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, sosave()raised aQueryExceptioninside the managers' transaction and rolled the whole write back —custom_fields[x]=1was 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 throughsetAttribute(), 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. - Case-variant keys.
-
A custom field could be created with a key that already named a protected column, permanently converting that column into a payload-writable field —
addColumnToTable()silently returns when the column already exists, and validation constrained only the key's character class and its uniqueness withincustom_fields. So a user withcustomFields.managecreating a field namedauthor_idcaused the existing column to be adopted rather than created, and from then on any content editor'scustom_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 throwsInvalidArgumentException, andCustomFieldRequestadds a matching field-level validation error, when the key already names a column on a target content type's table or appears in the newCustomFieldManager::RESERVED_FIELD_KEYSlist. -
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, andbootHasCustomFields()flushes that memo on everysaved/deleted, so a bulk import re-queried on every iteration. The memo now lives on the container-boundCustomFieldManagersingleton, keyed by content type and invalidated bycreateField()/updateField()/deleteField()/registerField(). -
A column-storage field's
default_valuewas 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 viaarray_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 deduplicatedLog::warning, and registering a field under a reserved key warns at registration time. -
Several smaller updater defects —
fclose()failures went unchecked alongside the short-write fix; a ZIP entry whosestatIndex()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& ~0022so a0777.phpfile cannot land under the docroot writable by a neighbouring tenant; the state store's temp file used a predictablegetmypid()-derived name written throughFile::put()(which follows symlinks) and is now randomized, created withx, andchmod 0600; backup directories are created0700rather than0755;UpdateStateStore::merge()no longer silently discards the whole record when a read fails;state_pathnow recognises Windows absolute paths;UpdateCheckercaches a primitive array rather than a serializedUpdateInfo, removing an object-injection sink on a shared cache;ApplicationUpdateManageris 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 barecomposer installthat cannot resolve on a Herd/FPM host; and operator guidance that hardcodedstorage/backups/application/now names the configuredbackup_path. -
update:statusrecovery 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 truncatedbackup-*.zipthat this advice invited them to extract over a healthy tree. Those steps now say to runphp artisan upand 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
CustomFieldManagerflushed 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 asmetadatastorage stayed dead; under Octane or a long-lived queue worker "the process" spans many requests. The listing now lives in the new table-keyedCustomFieldColumnCache, flushed byaddColumnToTable()andremoveColumnFromTable()and exposed asHasCustomFields::flushCustomFieldsRealColumnsCache()for hosts that alter these tables themselves. It previously lived in aprotected staticproperty 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_updateracing an operator'supdate:perform. Both would put the site down, both would extract overbase_path(), and both would runcomposer installin 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-10upalso undid run B's step-1down, serving traffic mid-extraction.performUpdate()now takes an exclusiveflockon a sentinel beside the state file (not a cache lock — step 8 runscache:clear) and additionally refuses to start when the persisted record saysin_progressand the PID it recorded is still alive. A stale marker from akill -9'd run does not wedge the updater. -
update:statusreported a failed rollback as a successful one —UpdateRunStatus::Failed's label assertedFailed (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 whenbackup_enabled = falseand when the failure landed beforecreateBackup()ran, in which case no rollback was attempted at all. The label is now plainFailed, and a separaterolled_backfield recordstrue/false/null(not attempted), whichupdate:statusrenders explicitly. -
The shutdown guard overwrote a terminal
Failedrecord withInterrupted, destroying the real error —performUpdate()'s catch marksFailedwith the actual message and then callshandleUpdateFailure(); 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:clearhiccup 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 beforeMigrations; later failures log that the snapshot was deliberately not restored and point atupdate: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.3while latest is2.0.0, the 1.2.3 archive was compared against 2.0.0's digest. That failed closed on its own, but combined withallow_unverified_updates=truethe pinned path installed an arbitrary older archive with no integrity check at all. The digest is now resolved for the target version via the newchecksumForVersion()on the GitLab and GitHub sources. -
There was no downgrade protection anywhere —
hasUpdate()is only ever consulted against latest, never against the requested target, soperformUpdate('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-downgradeis 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()threwchecksumRequiredon every update from GitHub, and the only way to make them work at all wasCMS_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.GitHubUpdateSourcenow discovers checksums the wayGitLabUpdateSourcedoes: a*.sha256release asset first, falling back to aSHA-256:marker in the release body. -
rollback()restored an arbitrary ZIP with no exclusion filter and no provenance check — unlikeextractUpdate(), it never consultedexclude_from_update, so a backup archive carrying.envorvendor/autoload.phpreplaced the live copies, andrunComposerInstall()then executed scripts from the restoredcomposer.json. With no argument,update:rollbackpicks the newestbackup-*.zipby mtime with no ownership check, so any other vulnerability yielding a file write understorage/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 --jsonexited 0 for failed and interrupted runs — the--jsonbranch returned success before the exit-code logic ran, sophp 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--jsonexited 1. The exit code is now resolved before the output branch and returned by both modes. -
composer testfatalled instead of producing a report —phpunit.xmlset no memory limit, so the documented invocation died at PHP's 128M default insidenikic/php-parserwhile Scramble's generator walked the route table for the OpenAPI spec test.phpunit.xmlnow setsmemory_limit=512M. -
./-prefixed ZIP entries bypassedexclude_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./.envwas a different string from.envand missed the list entirely, whilerealpath( dirname( '/base/./.env' ) )is just/base, so the containment check waved it through as well. Mixing one such entry in with normalapp/…entries also made the first path segments differ, sodetectCommonRootPrefix()returned null and the entry survived verbatim. The reachable targets were exactly the ones the operator believes are protected:APP_KEYand 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), anddatabase/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 updated —str_starts_with( 'storage-helpers.php', 'storage' )is true, sostorage-helpers.php,storage.php,vendors/,.envelope.jsonand 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'sUpCommand::handle()andDownCommand::handle()both wrap their bodies in atry { … } catch { …; return 1; }, so a genuine failure — permission denied unlinkingstorage/framework/down, an unwritablestorage/framework/— arrives as a non-zero exit code and never as an exception. Thecatch ( Throwable )was effectively dead code and the exit code was never checked. A failingupat step 10 therefore "succeeded": the active flag was cleared, the shutdown guard disarmed, and the run markedCompleted— leaving the site serving 503 to every visitor whileupdate:statusreported a clean finish, which is precisely the outcome the interruption machinery exists to prevent. Symmetrically, a failingdownat step 1 let the update proceed to overwrite application files on a live site. Both calls now check the exit code and throwmaintenanceModeFailure. -
fwrite()short writes were unchecked, so a disk that filled mid-extraction silently truncated PHP files — the return value was discarded andfclose()'s was too, so the write loop completed, the entry counted as extracted, and the update proceeded intocomposer installand migrations over truncated source. The asymmetry was the giveaway:fread()failure was checked and threwextractionEntryFailed, whose own docblock explains that throwing is what engages the rollback machinery — the write side never engaged it. The copy loop now lives instreamEntryToDisk(), which throws on a short write, on afalsewrite, and on a failedfclose()(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 ran —
File::makeDirectory( …, recursive: true )executed first andisPathWithinExtractRoot()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 insidebase_path()pointing outside it — routine in Envoyer/Forge/Deployer layouts wherestorage,public/uploadsorbootstrap/cacheare symlinked to shared directories. Impact was directory creation only, since file content was correctly withheld by the check beforefopen(). Both checks now run before theirmakeDirectory()call, validating the nearest existing ancestor. The unreachablecontinuein 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@chmodthat follows chmod'd the link target. The archive cannot introduce a symlink — every entry is written as a regular file andsymlink()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'sphpfirst since #225, annotated "so hosts that use Herd for both FPM and CLI stay on a single toolchain". Herd bundles composer in that samebin/directory, butcomposerCandidatePaths()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 tobrew installanother), all five candidate paths missed, discovery returnednull, and the updater fell through to barecomposer install— which PHP-FPM's strippedPATH(/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/composernow leads the composer candidate list, mirroring the PHP list's ordering and rationale. Herd's composer is a#!/usr/bin/env phpscript 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 singleherdBinPath()helper, so they cannot drift apart again the way they did here. -
A failed rollback
--versionprobe against a path PHP also cannotstat()now throws the newUpdateException::configuredComposerBinaryMissing, naming the offending path. Such a path can only have come fromCOMPOSER_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/composeris advertised in thecomposerBinaryNotFoundmessage and is the canonical macOS location, the natural next move on a Herd-only machine was to setCOMPOSER_BINARYto it — producing "Composer binary was located but could not be executed … Could not open input file" closing with aCMS_PHP_BINARYhint. It was never located, only configured; andresolvePhpBinary()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 pointingCOMPOSER_BINARYat such a path is the documented workaround for that case. A pre-flightis_file()gate would have closed that escape hatch. Probes that succeed are still honoured regardless of whatis_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.lockfrom extraction but rancomposer install, breaking every release that changed a dependency constraint (#255) —composer.locksat in theexclude_from_updatedefault annotated// Rebuilt via composer install. It isn't:composer installonly ever reads a lock file and aborts when it disagrees withcomposer.json; onlycomposer update/composer requirewrite one. The identical comment on the neighbouringvendorentry is correct, which made the pair easy to read past. The consequence was thatextractUpdate()skipped the release's lock — leaving the old one in place — whilecomposer.jsonwas 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_BINARYworkarounds 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.lockis no longer excluded from extraction, so the release's lock lands beside itscomposer.jsonand 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-synccomposer.locka requirement of the release archive; free for theauto_archivestrategy, and now documented forrelease_asset. The pre-update snapshot picks the lock up for the same reason, so a rollback restores the oldcomposer.jsonand 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_synckey above. - The misleading
// Rebuilt via composer installcomment 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_updateinconfig/cms/updates.phpwithcomposer.lockomitted;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 acms.updates.composer_timeoutbudget (default 600s), but the parent PHP request was still governed bymax_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, soperformUpdate()'scatchblock never ran: no rollback, and step 10'sdisableMaintenanceMode()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()androllback()callset_time_limit( 0 )andignore_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 putset_time_limitindisable_functionsget a warning log namingphp artisan update:performas 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'srequest_terminate_timeout, neither of whichset_time_limit()can override. It logs acriticalentry naming the step it died on, and ifartisan upitself fails (likely when shutting down after an OOM fatal) it removesstorage/framework/downdirectly.- 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:performfrom 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 toparent::__set(), which wrote it straight into the column.BlogManager::applyCustomFields()andPageManager::applyCustomFields()(#250) assigned each payload key that way, and the custom-field half of a request payload gets no fillable filtering — socustom_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, socreate()/update()are covered onPostandPage, 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_fieldshas nostoragecolumn, soCustomField::storageMode()resolves an existing row tocolumn). The exemption keys offexists, whichCustomFieldManager::filterFieldsForContentType()forces tofalseon every filter-registered field — so a plugin cannot buy itself a real-column write by declaringstorage => 'column', and only a row in thecustom_fieldstable, 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 totitlecould stop host code from writing$post->titleat 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.