Search by

asdrubalp9 / laravel-legal-consent

Drup9

Versioned legal documents and auditable user consent for Laravel.

Package info

gitlab.com/asdrubalp9/laravel-legal-consent

Issues

pkg:composer/asdrubalp9/laravel-legal-consent

Statistics

Installs: 29

Dependents: 0

Suggesters: 0

Stars: 0

v0.1.0 2026-09-28 15:48 UTC

This package is auto-updated.

Last update: 2026-09-28 20:22:16 UTC


README

Version legal documents and record auditable user consent for Laravel apps.

pipeline status

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.

MethodRouteResponse
GET/legal/documents/{key}Current version: document, version_id, version, title, body, format, effective_at

User

Configurable middleware, default ['api', 'auth:sanctum'].

MethodRouteAction
GET/legal/consentStatus of every document for the current user
POST/legal/consent/acceptAccept one or more versions
POST/legal/consent/{key}/rejectReject an optional document
POST/legal/consent/{key}/revokeRevoke an optional document
GET/legal/consent/historyThe 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')).

MethodRouteAction
GET/legal/admin/documentsList documents with their current version
POST/legal/admin/documentsCreate a document
PATCH/legal/admin/documents/{key}Edit name. type only changes with no published versions
GET/legal/admin/documents/{key}/versionsList versions with their acceptance count
POST/legal/admin/documents/{key}/versionsCreate 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}/publishPublish. Takes effective_at and requires_reacceptance
GET/legal/admin/exportDownload documents and published versions as JSON
POST/legal/admin/importLoad 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.

CodeHTTPCause
LEGAL_CONSENT_REQUIRED403The user has required documents pending
LEGAL_ADMIN_FORBIDDEN403The admin ability is missing
DOCUMENT_NOT_FOUND404Unknown key
DOCUMENT_HAS_NO_CURRENT_VERSION404The document has no current version
VERSION_NOT_FOUND404Unknown version id
VERSION_NOT_CURRENT409An attempt to accept a version that is not current
VERSION_IMMUTABLE409An attempt to edit, delete or re-publish a published version
DOCUMENT_TYPE_LOCKED409An attempt to change type with published versions
DOCUMENT_NOT_OPTIONAL422reject or revoke on a required document
OPTIONAL_BUNDLED_WITH_REQUIRED422accept mixes required and optional versions
INVALID_EFFECTIVE_AT422Date in the past, or earlier than the last published version
IMPORT_INVALID422The imported JSON does not match the schema
CONSENT_IMMUTABLE500The 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/accept rejects a request that mixes required and optional versions with 422 OPTIONAL_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 DocumentVersion holds a single text. A multi-language document needs one document per language.
  • The query builder bypasses immutability. VersionImmutableException fires from Eloquent's updating and deleting model events. A direct query builder update or delete on legal_document_versions skips it.
  • Restoring a soft-deleted user does not clear subject_closed_at. The package sets it when the user's deleted event 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.