setono/consent-contracts
Framework-agnostic PHP contracts for consent management. setono/consent-contracts defines the interfaces your app and third-party consent providers implement, keeping cookie and GDPR consent logic decoupled, testable, and easy to swap between implementations.
composer require setono/consent-contracts
This is a contracts-only package: it ships interfaces and (probably) an enum for consent types, with no ServiceProvider, no config file, no publishable assets, and no Laravel auto-discovery. That is the whole point — it exists so that libraries and apps can talk about consent without depending on a concrete CMP (Cookiebot, CookieYes, OneTrust, Usercentrics, or your own banner).
Where to look first:
src/ directory — read every interface before writing a line of code. Expect roughly: a ConsentInterface (a named consent a subject has/hasn't given), a ConsentCheckerInterface (the hasConsent(...) question your app actually asks), a storage/persistence contract, and a ConsentType/category enum describing the classes of consent.README.md — for the intended vocabulary and the "mainstream CMP" category mapping introduced in v1.1.0.Your first use case should be the narrow one: stop asking your app "is this user opted in to marketing?" in twenty different places. Bind ConsentCheckerInterface in your AppServiceProvider to a small implementation backed by whatever you already have (a cookie, a user_consents table, or your CMP's JS API mirrored server-side). Everything else — banners, scripts, tracking — can then depend on the interface rather than on a vendor SDK.
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->scoped(ConsentCheckerInterface::class, CookieConsentChecker::class);
}
Bind, don't extend. The package gives you the shape of the conversation; you supply the answer. A typical CookieConsentChecker reads a decoded cookie bag and answers from a pre-normalised array, falling back to DB rows for logged-in users so that consent survives a cleared cookie jar.
Single mapping table for categories. The v1.1.0 wording change is a reminder that every CMP names its categories slightly differently. Keep exactly one place in your app where external category names map to the package's taxonomy, and test it:
// config/consent.php
return [
'map' => [
'necessary' => ConsentType::Necessary,
'preferences' => ConsentType::Preferences,
'statistics' => ConsentType::Statistics,
'marketing' => ConsentType::Marketing,
],
];
Route-level enforcement via middleware. Don't scatter if (! $checker->hasConsent(...)) through controllers. A RequiresConsent middleware on a route group is declarative and greppable:
Route::middleware('consent:marketing')->group(function () {
Route::get('/pricing', PricingController::class);
});
Blade directive for the view layer. A @consent('statistics') … @endconsent directive keeps third-party script tags out of the markup when consent is absent — useful for the server-rendered half of your analytics snippet, and it makes audits trivial.
Context/subject handling. Anonymous visitors and logged-in users usually have different consent lifecycles (cookie-scoped vs. account-scoped). Resolve the subject once per request in a scoped() binding, and have the checker consult it — this avoids the classic bug where a logged-in user's stored consents are ignored because the cookie was never set.
Write an audit trail, not just a boolean. Even if the checker only answers yes/no today, persist (subject, consent, granted, source, ip/ua hash, occurred_at) when consent changes. GDPR defensibility is the reason people adopt this package, and retrofitting history is painful.
Fake it in tests. Because it's an interface, $this->app->bind(ConsentCheckerInterface::class, fn () => new AlwaysGrantedChecker()) gives you a one-liner test double. Assert both branches: content present when granted, and a 403/absent marker when not.
Decorator for caching. If your checker hits the database per call, wrap it: CachedConsentChecker implements ConsentCheckerInterface holding a per-request memo array. Decoration keeps the contract clean and is the intended extension point — no subclassing of package code.
Statistics → Analytics), it is a backward-compatibility break shipped under a minor version — anything persisted as a string will no longer round-trip through ::from(). Diff src/ between tags before upgrading, and if you have stored consent rows, write a migration that maps old values to new ones.Target [ConsentCheckerInterface] is not instantiable is the error you'll hit if you forget the container binding. There's no service provider to do it for you — that's by design, but it surprises people coming from batteries-included packages.ConsentType::tryFrom($raw) and handle null explicitly; CMP payloads routinely contain categories you don't model, and a thrown ValueError inside a Blade directive is an ugly 500 on the checkout page.scoped() or singleton with an explicit flush in middleware; a long-lived queue worker holding a stale checker is a real GDPR problem.<script> that's already in the page. If a tag fires from JS before your server-rendered check runs, you have a compliance gap the interface can't close — gate scripts at the source."setono/consent-contracts": "^1.1") and read the diff on every bump. For a package this small, the upgrade cost is minutes — but only if you actually look, because a semver-minor here can still change the values you've persisted.How can I help you explore Laravel packages today?