Skip to main content

Command reference

CommandWhat it does
legal-consent:publish {key?} {locale?} --editorial|--info|--deemed|--activeFreeze the current source into a new active version under a notice mode (--material is the legacy alias of --active). Flags below.
legal-consent:check-drift {key?} {locale?}Non-zero exit when a source has drifted from its published version (CI or cron). Narrow it to one document or locale with the optional arguments.
legal-consent:rerender {key?} {locale?}Re-freeze published versions whose text is unchanged and whose rendering has moved — the answer to a PRESENTATION only drift report. Publishes the identical text again as the next patch, silently. Refuses any document whose text really did change. See below.
legal-consent:dispatch-notices [--dry-run] [--force]Notify subjects of a due legal change, routing by notice mode (hourly, idempotent, auto-scheduled). --dry-run reports the audience of every due version and sends nothing, writes no proof, stamps no watermark. --force sends anyway where the audience exceeds notifications.max_recipients_per_run.
legal-consent:changes {key} {locale} [--clear]Print or discard the pending change description for a document and locale. Authoring goes through the ChangeItems facade.
legal-consent:renotify {key} {locale} {version?} [--tenant=]Clear a version's notice watermark so the next sweep considers it again. For a version whose sweep reached nobody — see below.
legal-consent:close-objection-windowsBind silence to deemed acceptance once a deemed-consent objection window closes (hourly, idempotent, auto-scheduled).
legal-consent:pruneDelete consent and notice records past the retention period (default 3 years); the current standing of an active subject is always kept. Not scheduled by default — see Retention.
legal-consent:cache-flush {key?} {locale?}Flush the cached, rendered documents and the enforceable-version set the gate reads per request. Needed after an out-of-band is_active write.
legal-consent:verify-ledgerVerify the tamper-evidence hash chain (non-zero exit on a break); only when tamper_evidence is on.
legal-consent:verify-documentsVerify every published row against its stored hash, and flag notice-mode divergence across a version's locales or a wording-locale mismatch (non-zero exit on a hard failure). Read-only — a frozen row is never repaired.
legal-consent:doctorCompare your published config file with the package's, report what drifted, name every registered document with no published version, and flag a config value that config:cache cannot serialize. Read-only. See below.

A published version keeps the HTML it was frozen with — that is what makes it proof. So when the renderer changes, and not the text, every published document that the change touches goes on showing the old output while legal-consent:check-drift reports a difference.

That is a real case, not a hypothetical: until the package registered CommonMark's table extension, a Markdown table came out as a paragraph full of pipe characters. Fixing it changed the HTML of every published text that held a table, and nobody had edited a word.

The two are told apart by what a row records about itself. Since 0.28.0 a published version carries the hash of the source it was rendered from and a fingerprint of the renderer that rendered it, so drift reports one of:

ReportWhat it meansWhat to do
TEXT changedthe source text differs from the published onepublish a new version and classify it
TEXT changed … and the renderer changed as wellboth moved at oncepublish a new version; the presentation change is frozen with it
PRESENTATION onlythe text is byte-for-byte the published onelegal-consent:rerender
predates the source hashpublished before the version carrying these columnscompare the text yourself, then publish once so the next check can tell

legal-consent:rerender freezes the identical text again under the next patch version and the silent notice mode: nobody is re-asked, no notice goes out, and no materiality decision is owed, because nothing anybody can be bound by has changed. Without arguments it walks the whole configured matrix, which is the point — a renderer change lands on every document at once.

It refuses a document whose text really did change, and it refuses one whose active version was published before the source hash existed. Neither is a fix you want done on a guess.

⚠️ It creates a new row rather than correcting the old one, and that is deliberate. Every consent in the ledger names the content hash it was given against, and the chain hashes it. A version that rewrote its own HTML would leave all of them pointing at a hash nothing can reproduce. The old version keeps its bytes; the new one carries the same text in the shape it should have had.

The sweep stamps notified_at once a version has been swept, and never revisits it. That is what keeps a clean re-run from mailing everyone twice — and it is also why a version whose sweep reached nobody can never repair itself.

That happened: until the audience became mode-dependent, an info-only or deemed-consent change published as a minor bump selected no subjects at all, reported success, and stamped the watermark anyway. If you published such a change before upgrading, its audience was never told.

php artisan legal-consent:renotify terms de 1.1.0
php artisan legal-consent:dispatch-notices --dry-run # look at the number first
php artisan legal-consent:dispatch-notices

It clears the watermark and nothing else — the content, the version and every existing proof row are untouched, and a version that was never swept is reported as such rather than silently "repaired". Omit the version to take the currently active one.

Run this after every upgrade. It answers a question no other command asks: does your published config/legal-consent.php still deliver what the package expects?

Publishing the config freezes a copy. The package merges its own defaults under that copy, and it descends into every block, so a key a later release adds inside a block your file already declares still arrives. Two things are left that the merge cannot reach:

  • A cached configuration is not merged at all. A key the package adds after the cache was built is absent at runtime until you run php artisan config:cache again. The doctor asks the runtime rather than comparing two files, which is why it can see this.
  • A list is yours whole, and so is the set of documents: the merge never adds an entry you left out. A shorter list is reported as a choice to confirm, not as a defect.

The reverse ages too. A key the package has since removed stays in your file and still reads like valid configuration — including entries naming classes that no longer exist, which fail when something resolves them and point at your config rather than at the upgrade that removed them.

php artisan legal-consent:doctor
These keys exist in the package and do not reach your runtime config:
Asked of the runtime, not of your file: this package merges its defaults UNDER a
published config recursively, so a key your file omits normally still arrives.
These did not, and a STALE config cache is the usual reason — one built before
the package added the key. A cached configuration skips the merge and serves
what it captured, so the key can only appear after the cache is rebuilt.

- cache.enforceable_ttl (package default: 60)
- routes.return_to_intended (package default: false)

These keys exist only in your published file — the package no longer defines them:

? sources.database.driver (your value: …\Content\Drivers\DatabaseDriver)

Your file carries fewer entries than the package default in these lists:

? locales (not in your list: de)

These documents are registered but have no published version:

? terms (en)

These config values are closures, so `php artisan config:cache` will fail:

? legal-consent.gate.subject_filter

It changes nothing. A key that does not reach the runtime comes back when you rebuild the configuration cache; copying it into your file instead would freeze today's default there, which is the state the recursive merge exists to prevent. Delete the stale keys once you have checked them — republishing with --force would discard your own values, which is the reason this reports instead of rewriting.

The closure report exists because the deploy is the first place it shows

Two keys accept a closure — gate.subject_filter and document_url — and a closure in either one makes php artisan config:cache abort the whole cache with a LogicException.

Where that bites is what makes it worth a report:

WhereResult
local, nothing cachedruns
your test suiteruns
deploy, config:cacheaborts

Nothing before the deploy reproduces it. Move the body into an invokable class and configure its class-string instead — both keys resolve one from the container, so the behavior is unchanged:

'subject_filter' => App\Legal\SubjectFilter::class,

doctor reports this and still exits 0: a closure is entirely valid until something caches, and plenty of installations never do.

A list is one value, not a set of keys

A listlocales is the one that matters — is compared as a set, and carrying fewer entries than the package default is reported without failing. Its length is your decision: locales is which legal documents exist in your application, so a single-language app carries one. Adopting the default to satisfy a check would mean publishing a second binding legal text, which is a worse outcome than the finding.

Exit codes

FindingExit
A key never reaches your runtime — your file says one thing, the runtime is another1
A configuration that contradicts itself (deemed consent with durable_medium.proof off)1
locale_fallback on a contract or a consent, which never fall back1
A name in middleware.rights_routes that is no route, so the right it was meant to keep open is behind the gate1
The gate guards a route and no route is named where a subject exports or deletes their data0
A stale key the package no longer defines0
A list you deliberately keep shorter0
A registered document with no published version — a language whose reader is shown another language's version is not counted0

The zero rows are real findings worth reading, and none of them means your configuration is wrong. That split is what lets this gate a deployment: a step that goes red on a state you chose — or on the state every fresh database is in — gets switched off, and the findings that do matter go with it. There is deliberately no flag to suppress the failing class, for the same reason in reverse. Your documents registry is never reported in either direction: curating it is your call.

Exactly one mode flag per publish; the rest are optional metadata.

FlagWhat it means
--editorialSilent activation — a typo or formatting fix that changes no obligation. No notice.
--infoActively announced, no action required, takes effect regardless.
--deemedSilence counts as acceptance. Contract and terms only; requires --objection-at.
--active (--material)The subject must actively accept before it applies.
--regime=The legal regime the change falls under: bgb_agb, psd2_675g, dcd_327r, gdpr, p2b, or eecc. Validated against that set — an unrecognized value is refused. Four of the six carry an advance period of their own: psd2_675g reads psd2_min_days (statutory floor 60 — § 675g's hard two months), p2b reads p2b_standstill_days (floor 15), eecc reads eecc_min_days (floor 30), and gdpr reads privacy_advance_days (no floor — WP260's "well in advance" is guidance, not a number). bgb_agb and dcd_327r declare none, because their period is the mode benchmark. The regime minimum and the mode minimum are combined with max(), never chosen between: a deemed-consent change under P2B owes both. An info-only change has no mode benchmark, so its regime is its only source — under P2B, EECC or GDPR an --info publish with too short an --enforce-at is refused, not waved through. See notice_periods.
--announce-at=ISO date the subjects are notified. Defaults to now.
--enforce-at=ISO date enforcement begins. Omit for an immediate publish.
--objection-at=ISO date the objection window closes (--deemed). Must leave the full statutory period after the announcement.
--offers-terminationThe notice offers a free right to terminate before the effective date (§ 675g / § 327r).
--keeps-unmodifiedThe subject may keep the unmodified version (the DCD / § 327r escape hatch).
--change-class=A free-form classification tag from your legal review (for example agb_minor_peripheral), stored with the version for the audit trail.
--allPublish every registered document in every configured locale.
--only-missingModifies --all: publish only combinations with no active version and leave every existing one untouched, including one whose source has drifted. The flag for a scripted caller — see below.
--dry-runResolve every source and report what a run would do. Writes nothing. Works on a single document and on --all.

--all in a deploy line: reach for --only-missing

--all is idempotent for unchanged sources, and that is what makes it safe to re-run. Once a source has drifted it is no longer a no-op — it is a publication, and it carries whatever mode stands on the line that invoked it. In a deploy script that mode is --editorial, because the first run legitimately is editorial. So a changed legal text would be filed as the one classification that notifies nobody, chosen by a script rather than by a person.

php artisan legal-consent:publish --all --only-missing --editorial # scripts
php artisan legal-consent:publish --all --editorial # a person who looked at the diff

--only-missing never reads the source of a combination that already has an active version, so it cannot classify a change at all. That is the whole of what the flag decides.

How a textless source is treated is a separate question, and it does not move with the flag:

  • A document waiting on an author is named and skipped, and the run stays green — under --only-missing and under the bare --all alike. An unwritten legal text is the normal state of an installation whose editors have not written it yet: there is no file anywhere to freeze, because the text is authored in the admin. It is named, never silent — a count alone would let a document sit unpublished for months behind a green deploy.

  • A document whose source is provisioned — a markdown file that should be in the repository — fails in both. Nobody is going to write that one; a deployment is missing a file, and skipping it produces the empty legal page this command exists to prevent.

    Unless its reader is shown another language's version, which is the case for an informational page and for an acknowledgement with 'locale_fallback' => true. There the language is named — imprint (en) reads de — and not counted as a failure, because its page is not empty. Decided at the end of the run, so a matrix that lists en before de is not failed over a version the same run publishes a moment later, and a dry run says the same.

    The two raise the same error, so the source is what decides. A source declares Pushery\LegalConsent\Content\AwaitsAuthoring when its empty state means a person has not written yet; the bundled draft source does. Your own source is treated as provisioned unless it declares that interface — the direction that fails loudly rather than skipping quietly. If yours is editorial (a CMS, a review queue), implement the marker; it declares no methods.

  • An unexpected source error is still a failure in both modes. "No text yet" is a state; a broken driver is a defect, and folding the second into the first would turn it into a green skip line.

Drift itself is reported by legal-consent:check-drift, which is the command for that question and stays sharp because the publish does not also try to answer it.

A major version bump forces its mode — the publisher refuses --info and --deemed on a major bump of a contract, and refuses anything but --info on a major privacy bump. The converse holds as well: --active on a minor or patch bump of a contract or a real consent is refused, because the gate compares major versions and would ask nobody who accepted the current one. See The four notice modes.