nowendwell/laravel-terms
A Laravel package for managing terms and conditions acceptance. Define versioned terms, track which users accepted which version, and require re-acceptance when policies change. Includes middleware, Blade directives, and Eloquent traits to integrate legal consent into your app.
nowendwell/laravel-terms handles the two halves of a terms-of-service flow: storing versioned term documents, and recording which users accepted which version. You provide the document text; the package gives you the persistence, the acceptance tracking, and (optionally) the routes/middleware to force re-acceptance.
Requirements: PHP 8.2+, and Laravel 10, 11, or 12. Laravel 12 support landed in v2.6.0, so pin ^2.6 if you're on a Laravel 12 codebase — earlier tags will fail dependency resolution against laravel/framework: ^12.0.
composer require nowendwell/laravel-terms
php artisan vendor:publish --provider="Nowendwell\Terms\TermsServiceProvider"
php artisan migrate
Publishing typically emits a config file and migrations for the terms table and a pivot/acceptances table. Publish tags selectively if you only want one (--tag=terms-config, --tag=terms-migrations) rather than dragging in views you'll replace.
Where to look first:
config/terms.php — this is where the model bindings, the middleware alias, redirect targets, and route registration live. Read it before writing any code; several behaviours (auto-registering routes, the redirect after acceptance) are toggled there rather than in your app code.Term model and its contract — you'll often want to extend or swap it via config.First use case: add the provided trait to your User model so acceptance state is queryable.
use Nowendwell\Terms\Concerns\HasTerms;
class User extends Authenticatable
{
use HasTerms;
}
Then gate a route group behind acceptance by registering the middleware alias (already present in the package's service provider, or aliased in bootstrap/app.php on Laravel 11/12):
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'terms' => \Nowendwell\Terms\Http\Middleware\EnsureTermsAccepted::class,
]);
})
Route::middleware(['auth', 'terms'])->group(function () {
Route::get('/dashboard', DashboardController::class);
});
That's the whole happy path: install, migrate, trait, middleware. Everything below is refinement.
Treat published terms as immutable. The single most important pattern: never edit an existing term row to "fix a typo in v3". Create a new row. The acceptance records point at a specific term ID, and the audit value of the whole package collapses the moment a document changes underneath an existing acceptance. If you genuinely need to correct a typo, decide explicitly whether it's a new version (forces re-acceptance) or whether you accept the drift — and if the latter, record it somewhere outside the package.
Seeding terms as part of deploy. A common workflow is a seeder or a deploy step that inserts the current terms document idempotently:
Term::firstOrCreate(
['version' => '2025-06-01'],
['title' => 'Terms of Service', 'body' => file_get_contents(resource_path('terms/tos.md'))]
);
Keying on a version string rather than content hash keeps re-deploys from creating duplicates.
Checking acceptance outside middleware. Middleware is right for whole route groups, but you frequently need the check inline — after a profile update, before a payment, inside a Livewire action:
if (! $user->hasAcceptedLatestTerms()) {
return redirect()->route('terms.show');
}
Wrap this in a small EnsureUserHasAcceptedTerms action or a Blade component rather than repeating the conditional. If you're building an Inertia/Vue app, share the flag once via HandleInertiaRequests::share() so the frontend can render a persistent banner instead of relying on a redirect:
'terms' => [
'accepted' => $user?->hasAcceptedLatestTerms() ?? false,
'url' => route('terms.show'),
],
API routes need an explicit decision. The package middleware redirects, which is wrong for JSON consumers. Either omit the middleware from api.php and check acceptance inside the controller returning a 403 with a machine-readable code, or add a Accept: application/json branch in your own wrapper middleware. Don't let a redirect leak into an API response — mobile clients will silently follow it and cache an HTML page.
Recording acceptance with context. The acceptances table generally stores user ID, term ID, and timestamp. If your compliance story needs IP address or user agent, add columns via your own migration and populate them in a listener on the acceptance event (or by overriding the controller action). Doing this after go-live means the historical rows are permanently gap-toothed.
Testing. Use RefreshDatabase and create terms through a factory in setUp() so the "latest term" is deterministic. Note that "latest" is usually resolved by publish date or ID — if you create terms in a test without ordering them, assertions get flaky. Explicitly set the published timestamp:
Term::factory()->published(now()->subDay())->create(['version' => '1.0']);
Term::factory()->published()->create(['version' => '2.0']);
Then assert both the negative case (assertRedirect(route('terms.show'))) and the accepted case (actingAs($user->acceptTerms(...))).
Re-acceptance campaign. When you publish a new version, everyone's hasAcceptedLatestTerms() flips to false. For a large user base, don't surprise users mid-task — sequence it: publish the term, but only start enforcing the middleware once you've shipped the banner/email. Publishing and enforcing in the same deploy is the most common source of "everyone got logged out of their flow" incident reports.
Localisation. If the package exposes a locale or slug column, use it rather than maintaining parallel Term rows in your own tables — but be aware that "latest accepted" then has to be evaluated per-locale or the check becomes ambiguous for bilingual users. Simplest reliable model: one term per version, translations stored as JSON columns or separate translation rows that share the parent term ID.
Middleware order matters. The terms middleware needs an authenticated user. Place it after auth in the group, or it will either error on null or silently pass for guests depending on the implementation. On Laravel 11/12, $middleware->appendToGroup('web', ...) puts it in the wrong place — use the alias and apply it per-route-group.
Laravel 12 upgrade path. If you're moving an existing app to Laravel 12, bump to ^2.6 first and re-run composer update nowendwell/laravel-terms. Then re-publish and diff the config and migrations with --force disabled — Laravel 12's skeleton changes (notably the bootstrap/app.php middleware registration replacing Kernel.php) mean any middleware alias you registered in app/Http/Kernel.php is now dead code that will silently not apply. This is the single most likely breakage from this release: the package supports Laravel 12, but your app's registration of its middleware probably doesn't yet. Verify with php artisan route:list --middleware=terms.
Config caching. php artisan config:cache is your enemy during setup. If changes to config/terms.php appear to do nothing in staging, run config:clear before debugging anything else. Same for route:clear if the package registers routes and you've toggled that flag.
Route name collisions. The package registers names like terms.show / terms.accept. If you already have a terms route prefix or a marketing page at /terms, you get either a duplicate-name exception or a silently shadowed route. Either disable the package's route registration in config and wire your own controller, or rename yours. Check with php artisan route:list | grep terms.
N+1 on the acceptance check. hasAcceptedLatestTerms() typically runs a query. Calling it in a Blade loop over 50 users is 50 queries. Eager-load the relationship (->with('terms')) and compare in memory, or cache the latest term ID in a singleton/Cache::rememberForever and invalidate on publish. Similarly, do not call it inside an accessor that gets serialised on every model — you'll fire it dozens of times per request.
Deleting users and terms. Check whether the acceptances foreign key cascades. Cascading on user delete destroys your audit trail — usually undesirable for a compliance feature. Prefer restrictOnDelete or soft-deletes on the user model. Deleting a term row should be impossible; there's no legitimate reason and it orphans acceptances.
Queued jobs and console commands bypass middleware entirely. A scheduled job acting on behalf of a user will happily mutate data for someone who hasn't accepted the current terms. If that matters, put the check in the job's handle() or in a shared action class rather than relying on HTTP middleware.
"Latest" is not "highest version string." Version strings like 1.10 vs 1.9 sort lexicographically and will pick the wrong row if you're comparing strings. Rely on the package's own resolution (typically published_at / ID ordering), and if you must sort yourself, sort by timestamp, never by the version label.
Debugging a user who "already accepted". Query the acceptances table directly by user ID before assuming a bug in the middleware — the usual cause is a stale term row created by a seeder during a deploy, which then became "latest" and un-accepted everyone.
Extension points. The config-driven model bindings are the intended customisation seam: subclass the Term model to add scopes or casts, point the config at your subclass, and you keep the package's controllers and middleware working. Avoid editing published vendor files or published migrations in place — you'll lose the diff on the next upgrade.
How can I help you explore Laravel packages today?