laravel-legal-consent maintained by asdrubalp9
Laravel Legal Consent
Version legal documents and record auditable user consent for Laravel apps.
What it does
The package versions two kinds of documents. Required documents, such as terms of service or a privacy policy, block a user until they accept the current version. Optional documents, such as marketing or analytics consent, the user accepts, rejects, or revokes at any time without losing access. Publishing a document's first version locks its type.
Every acceptance points to a fixed version of the text, identified by a
content hash. A published version never changes: editing or deleting it
through Eloquent throws VersionImmutableException, and a consent event
never gets an Eloquent update or delete either. This immutable evidence
is what proves, to a data protection authority, that a given user consented
to a given text at a given time.
Installation
composer require asdrubalp9/laravel-legal-consent
php artisan vendor:publish --tag=legal-consent-config
php artisan vendor:publish --tag=legal-consent-migrations
php artisan migrate
Minimal configuration
Add the trait to your user model:
use Asdrubalp9\LegalConsent\Concerns\HasLegalConsents;
class User extends Authenticatable
{
use HasLegalConsents;
}
Define the ability the admin routes check. Without it, every admin route responds 403, so the routes start closed:
Gate::define('manage-legal-documents', fn (User $user) => $user->is_admin);
A Gate::before callback in the host app runs before this definition. If it
returns true for a role, such as a super-admin bypass, that role gets
access to the admin routes regardless of manage-legal-documents. This is
standard Laravel Gate behavior, not something the package controls.
If your app blocks writes while a required document is pending, add the
legal.consent middleware to the routes it should block:
Route::middleware(['auth', 'legal.consent'])->group(function () {
// your app routes
});
By default routes.user_middleware and routes.admin_middleware are both
['api', 'auth:sanctum']. Your host app needs laravel/sanctum installed
for that default to work. Without Sanctum, override both keys in
config/legal-consent.php to whatever guard your app uses.
Sign-up
The sign-up form requests GET /legal/documents/{key} for every required
document and submits the version_ids it showed. The CurrentLegalVersions
rule checks that they are the current version of every required document.
After creating the user, the app calls acceptLegalVersions():
$request->validate(['legal_version_ids' => ['required', new CurrentLegalVersions]]);
$user->acceptLegalVersions($request->input('legal_version_ids'), $request);
Endpoints
All responses are JSON. See Error codes for the package's own error shape.
Public
No authentication. Disabled with routes.public = false.
| Method | Route | Response |
|---|---|---|
| GET | /legal/documents/{key} |
Current version: document, version_id, version, title, body, format, effective_at |
User
Configurable middleware, default ['api', 'auth:sanctum'].
| Method | Route | Action |
|---|---|---|
| GET | /legal/consent |
Status of every document for the current user |
| POST | /legal/consent/accept |
Accept one or more versions |
| POST | /legal/consent/{key}/reject |
Reject an optional document |
| POST | /legal/consent/{key}/revoke |
Revoke an optional document |
| GET | /legal/consent/history |
The user's event history (right of access) |
GET /legal/consent responds:
{
"required_pending": [
{ "document": "terms", "name": "Terms and conditions", "version_id": 12, "version": 3,
"title": "...", "body": "...", "format": "markdown", "change_summary": "..." }
],
"optional": [
{ "document": "marketing", "name": "Marketing communications", "version_id": 9,
"version": 1, "status": "pending" }
]
}
Administration
Configurable middleware, default ['api', 'auth:sanctum']. Each action
checks Gate::allows(config('legal-consent.admin_ability')).
| Method | Route | Action |
|---|---|---|
| GET | /legal/admin/documents |
List documents with their current version |
| POST | /legal/admin/documents |
Create a document |
| PATCH | /legal/admin/documents/{key} |
Edit name. type only changes with no published versions |
| GET | /legal/admin/documents/{key}/versions |
List versions with their acceptance count |
| POST | /legal/admin/documents/{key}/versions |
Create a draft. from_current: true copies the current text |
| GET | /legal/admin/versions/{id} |
Show a version |
| PATCH | /legal/admin/versions/{id} |
Edit a draft |
| DELETE | /legal/admin/versions/{id} |
Delete a draft |
| POST | /legal/admin/versions/{id}/publish |
Publish. Takes effective_at and requires_reacceptance |
| GET | /legal/admin/export |
Download documents and published versions as JSON |
| POST | /legal/admin/import |
Load that JSON as drafts |
Error codes
The package's own errors use {"error": {"code": "...", "message": "..."}}.
Error messages are in Spanish, since they reach the app's own users:
{ "error": { "code": "VERSION_NOT_CURRENT", "message": "La versión 12 ya no es la vigente. Vuelve a pedir los pendientes." } }
Two responses keep a different shape, because the package does not produce
them. A 422 from input validation keeps Laravel's native
{"message": "...", "errors": {...}} shape. A 401 comes from the host app's
own authentication middleware.
| Code | HTTP | Cause |
|---|---|---|
LEGAL_CONSENT_REQUIRED |
403 | The user has required documents pending |
LEGAL_ADMIN_FORBIDDEN |
403 | The admin ability is missing |
DOCUMENT_NOT_FOUND |
404 | Unknown key |
DOCUMENT_HAS_NO_CURRENT_VERSION |
404 | The document has no current version |
VERSION_NOT_FOUND |
404 | Unknown version id |
VERSION_NOT_CURRENT |
409 | An attempt to accept a version that is not current |
VERSION_IMMUTABLE |
409 | An attempt to edit, delete or re-publish a published version |
DOCUMENT_TYPE_LOCKED |
409 | An attempt to change type with published versions |
DOCUMENT_NOT_OPTIONAL |
422 | reject or revoke on a required document |
OPTIONAL_BUNDLED_WITH_REQUIRED |
422 | accept mixes required and optional versions |
INVALID_EFFECTIVE_AT |
422 | Date in the past, or earlier than the last published version |
IMPORT_INVALID |
422 | The imported JSON does not match the schema |
CONSENT_IMMUTABLE |
500 | The code tried to edit or delete a consent through Eloquent. This is a programming error |
Rules for your app's interface
The package does not control your UI, so it declares these rules mandatory instead:
- Never bundle an optional consent with a required one. The law presumes
consent is not free when a contract that does not need it requests that
consent. Your required-acceptance modal must not include optional
checkboxes for marketing or analytics.
POST /legal/consent/acceptrejects a request that mixes required and optional versions with 422OPTIONAL_BUNDLED_WITH_REQUIRED. - Revoking must cost the same as accepting. If your app grants an optional consent with one click, it must offer a one-click way to revoke it from the user's account, permanently available.
- Say "I have read" for a policy, and "I accept" for terms. A privacy policy informs. It does not request consent. Account processing rests on the contract, not on consent. Asking a user to "accept the privacy policy" does not create a lawful basis for anything. Reserve "accept" for documents the user is actually consenting to, such as the terms of service or an optional marketing consent.
When to mark requires_reacceptance
Mark a new version requires_reacceptance when it adds a new purpose, a new
category of data, or a new recipient that a prior acceptance did not cover.
A wording change alone does not require it. When in doubt, mark it: treating
data for a purpose the user never consented to is the costlier mistake. The
first published version of any document always has
requires_reacceptance = true, regardless of what the request sends, since
nobody accepted anything before it.
Migrating existing texts
php artisan legal:import-markdown resources/legal/terminos-y-condiciones.md --key=terms --name="Términos y condiciones"
php artisan legal:publish 1 --effective-at="2026-12-01 00:00"
legal:import-markdown reads front matter from the file when present.
Publishing through the command has the same effect as
POST /legal/admin/versions/{id}/publish.
Retention
A consent event expires retention_years years (default 4) after its
end date:
- Optional document: the date of the user's next event on the same document (a rejection, a revocation, or acceptance of another version).
- Required document: the date the user accepted a later version, or the
date the user closed their account (
subject_closed_at). - An event with no end date never expires.
Once expired, legal:prune anonymizes the row: it nulls consentable_type
and consentable_id, drops ip_address and user_agent, and stamps
anonymized_at. The version, the action and the timestamp remain, which is
enough for statistics and identifies nobody. Schedule the command daily:
Schedule::command('legal:prune')->daily();
Limits of version 0.1.0
- User IDs are integers. The consent table uses
nullableMorphs. UUID primary keys are out of scope for this version. - One language per version. A
DocumentVersionholds a single text. A multi-language document needs one document per language. - The query builder bypasses immutability.
VersionImmutableExceptionfires from Eloquent'supdatinganddeletingmodel events. A direct query builder update or delete onlegal_document_versionsskips it. - Restoring a soft-deleted user does not clear
subject_closed_at. The package sets it when the user'sdeletedevent fires and never clears it automatically, so a restored user still looks account-closed to the retention policy until you clear the column yourself.
Testing
Run the package's own suite:
vendor/bin/phpunit
vendor/bin/pint --test
License
MIT. See LICENSE.