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: 160

Dependents: 0

Suggesters: 0

Stars: 0

v0.3.0 2026-10-09 18:54 UTC

This package is auto-updated.

Last update: 2026-10-09 21:56: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

The package migrations support PostgreSQL and SQLite. Other database drivers throw a RuntimeException during migration.

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);

Scopes (multi-tenant apps)

A scope_id column lets the same key exist for different tenants or organizations. A null scope is a platform-wide document. Two contracts decide how scopes apply:

  • ResolvesApplicableDocuments decides which documents apply to a subject.
  • ResolvesRequestScope decides which scope an HTTP request belongs to. The admin routes, the public route and the export and import endpoints use it.

Documents that apply to a subject

use Asdrubalp9\LegalConsent\Contracts\ResolvesApplicableDocuments;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(ResolvesApplicableDocuments::class, function ($app) {
            return new TenantDocuments($app->make(TenantContext::class)->id());
        });
    }
}

The resolver returns a list of ['scope' => ..., 'key' => ...] pairs. The package discards retired documents afterwards. At sign-up, pass the scope to the rule:

new CurrentLegalVersions($tenantId)

The scope of a request

use Asdrubalp9\LegalConsent\Contracts\ResolvesRequestScope;
use Illuminate\Http\Request;

final class TenantRequestScope implements ResolvesRequestScope
{
    public function scopeFor(Request $request): int|string|null
    {
        $tenant = app('tenant.company');

        return $tenant->isPlatform() ? null : $tenant->id;
    }
}

// AppServiceProvider::register()
$this->app->bind(ResolvesRequestScope::class, TenantRequestScope::class);

Derive the scope from data your app already trusts, such as the host or the session. Never read it from user input. A user who picks the scope administers the documents of another tenant. The default resolver, PlatformScope, always returns null. An app without tenants behaves as in 0.2.

A value that does not match scope_type raises INVALID_SCOPE (HTTP 500). It is a configuration error in the app's resolver, not a user error.

Administration per scope

Admin routes operate on the scope that the resolver returns. The Gate receives that scope as its argument:

Gate::define('manage-legal-documents', function (User $user, int|string|null $scope) {
    return $scope === null ? $user->hasRole('admin') : $user->managesTenant($scope);
});

A closure defined as fn (User $user) => ... keeps working, because PHP ignores the extra argument. Your Gate must check the scope. If it does not, any user who passes it administers every scope.

Laravel treats the first Gate argument as a possible policy target. If the scope is a string equal to a class name that has a registered policy, Laravel calls that policy instead of your closure. It only does so when the policy defines a method named after the ability, such as manageLegalDocuments for manage-legal-documents. With scope_type set to string, do not use class names as scope ids.

Inside a scope, a document or a version of another scope does not exist. The API answers DOCUMENT_NOT_FOUND or VERSION_NOT_FOUND (404) and does not reveal that the key exists elsewhere. POST /legal/admin/documents ignores any scope_id in the body.

Public reading per scope

GET /legal/documents/{key} returns the current version of the resolved scope. If that scope has no such document, it answers 404. It never falls back to the platform document.

Ambiguous keys

If the same key applies to a subject in two scopes, reject, revoke and hasConsent need the scope:

LegalConsent::hasConsent($user, 'marketing', $tenantId);
LegalConsent::reject($user, 'marketing', scope: $tenantId);
$user->hasConsent('marketing', $tenantId);

A null scope does not filter, so it cannot pick the platform document when the key is ambiguous. Over HTTP, pass ?scope=, for example POST /legal/consent/marketing/reject?scope=12. The value only selects among the scopes that already apply to the user. Without it, an ambiguous key answers AMBIGUOUS_DOCUMENT_KEY (409).

Cache keys

The published-versions cache uses the key legal-consent:published-versions:p for the platform and legal-consent:published-versions:s:<scope> for a scope. The 0.2 keys, such as legal-consent:published-versions:platform, are a different set. See Upgrading from 0.2.

Console commands

legal:export, legal:import, legal:import-markdown and legal:publish take --scope=. Without it they operate on platform documents. legal:publish only finds drafts of the given scope. An invalid value ends the command with exit code 1 and changes nothing.

The consents.scope_id column is copied from the document so host applications can write row-level security policies without joins.

Key types

The migration and models support configurable key types. Set them before running migrations:

'key_type' => 'int',          // 'int' or 'uuid' for package primary/foreign keys
'morph_key_type' => 'int',    // 'int' or 'uuid' for consentable, acted_by, created_by, published_by
'scope_type' => 'int',        // 'int', 'uuid' or 'string' for scope_id

Changing the key type on an existing database is not supported by the upgrade migration.

Upgrading from 0.2

0.3 needs no migration and no new config keys. The cache needs one step.

0.2 reads the keys legal-consent:published-versions:platform and legal-consent:published-versions:<scope>. 0.3 reads legal-consent:published-versions:p and legal-consent:published-versions:s:<scope>. Both versions store the entries forever and each one deletes only its own keys when you publish. A version that you switch back to can serve an entry that is older than the last publication.

After every deploy or rollback that crosses 0.2 and 0.3, clear the cache. Run php artisan cache:clear, or delete the keys that match legal-consent:published-versions:*. Do not run workers of both versions on the same cache store at the same time.

Review these changes before you upgrade:

  • A route of the admin group that does not use AuthorizeLegalAdmin answers 500.
  • hasConsent() without a scope throws AMBIGUOUS_DOCUMENT_KEY when the key applies in more than one scope.
  • The Gate, Gate::before(), Gate::after() and the GateEvaluated event receive [null] as arguments for the platform. 0.2 passed [].
  • The controllers are not final. If you extend them, update the signatures listed in the CHANGELOG.

Upgrading from 0.1

Run the upgrade migration after publishing it:

php artisan vendor:publish --tag=legal-consent-upgrade-0.2
php artisan migrate

The upgrade keeps every row and converts timestamps to timestamptz on PostgreSQL. It does not change the key type.

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 of the request's scope: document, scope, 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. Optional ?scope=
POST/legal/consent/{key}/revokeRevoke an optional document. Optional ?scope=
GET/legal/consent/historyThe user's event history (right of access)

GET /legal/consent responds:

{
  "required_pending": [
    { "document": "terms", "scope": null, "name": "Terms and conditions", "version_id": 12, "version": 3,
      "title": "...", "body": "...", "format": "markdown", "change_summary": "..." }
  ],
  "optional": [
    { "document": "marketing", "scope": null, "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'), [$scope]) and operates only on the scope of the request. See Scopes.

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 the documents and published versions of the request's scope as JSON
POST/legal/admin/importLoad that JSON as drafts in the request's scope

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 in the resolved scope
DOCUMENT_HAS_NO_CURRENT_VERSION404The document has no current version
VERSION_NOT_FOUND404Unknown version id, or a version of another scope
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
VERSION_NOT_APPLICABLE422accept with a version whose document does not apply to the subject
AMBIGUOUS_DOCUMENT_KEY409The same key applies to the subject in more than one scope, and reject, revoke or hasConsent got no scope
DOCUMENT_RETIRED409Creating or publishing a version of a retired document
INVALID_SCOPE500ResolvesRequestScope or --scope gave a value outside scope_type. This is a configuration error
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.

Actor

accept(), reject() and revoke() accept an optional $actor model. When the actor is null or is the same subject, acted_by is left empty. Use it when an owner accepts terms on behalf of an organization:

$legal->accept($organization, [$terms->id], $request, $owner);

The actor is included in GET /legal/consent/history.

Retiring documents

A retired document stops applying to every subject but keeps its versions and consent events evidence. It rejects new drafts and publishing existing drafts with DOCUMENT_RETIRED (409).

$legal->retire($document);
$legal->reinstate($document);

When a retired optional document is reinstated, subjects keep the state they had before retirement.

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

  • 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.