itsjustvita / laravel-bfsg
BFSG and WCAG 2.1 accessibility checks for Laravel: a CI-ready check command, middleware, reports, English and German messages, and an MCP server
Requires
- php: ^8.2
- ext-dom: *
- ext-libxml: *
- ext-mbstring: *
- guzzlehttp/guzzle: ^7.8.2 || ^8.0
- guzzlehttp/psr7: ^2.6.2 || ^3.0
- illuminate/console: ^12.0 || ^13.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/process: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- illuminate/view: ^12.0 || ^13.0
- symfony/css-selector: ^7.0 || ^8.0
- symfony/process: ^7.0 || ^8.0
Requires (Dev)
- barryvdh/laravel-dompdf: ^3.0
- laravel/mcp: ^0.6.4 || ^1.0
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^11.5 || ^12.0
Suggests
- barryvdh/laravel-dompdf: Required for PDF report generation (^3.0)
- laravel/mcp: Required for the MCP server and the bfsg:mcp-server command (^0.6.4 || ^1.0)
Provides
None
Conflicts
- laravel/mcp: <0.6.4
Replaces
None
README
Accessibility checks for Laravel applications, built around the German Barrierefreiheitsstärkungsgesetz (BFSG) and WCAG 2.1 level AA. The package analyzes HTML with 16 analyzers and reports every finding with a stable key, a severity, the WCAG success criterion, the element, and a message in English or German. Use it on the command line and in CI (bfsg:check), as middleware, from PHP, or from an AI assistant through its MCP server.
Automated checks find a part of the accessibility problems of a page, not all of them. A clean report does not prove BFSG conformance. Use it to catch regressions early, and test with real users and assistive technology as well.
Upgrading from 2.x? Read UPGRADE.md.
Contents
- Requirements
- Installation
- Quick start
- bfsg:check
- Checking pages behind a login
- Browser mode for single-page apps
- bfsg:history
- Middleware
- Programmatic API
- Custom analyzers
- Reports and score
- Localization
- Persistence
- MCP server
- Blade component
- Configuration reference
- Analyzers
- Limitations
- Development
Requirements
- PHP 8.2 or higher with the
dom,libxmlandmbstringextensions - Laravel 12 or 13
- Optional:
laravel/mcp(^0.6.4 || ^1.0) for the MCP server - Optional:
barryvdh/laravel-dompdf(^3.0) for PDF reports - Optional: Node.js with Playwright for
bfsg:check --browser
Installation
composer require itsjustvita/laravel-bfsg:^3.0
The service provider and the Bfsg facade are discovered automatically. The package loads its migrations itself, so create the tables for stored reports with:
php artisan migrate
Everything else is optional to publish:
| Tag | Publishes | When you need it |
|---|---|---|
bfsg-config |
config/bfsg.php |
to change settings beyond the environment variables |
bfsg-lang |
lang/vendor/bfsg/{en,de} |
to reword messages or add a locale |
bfsg-views |
resources/views/vendor/bfsg |
to change the HTML/Markdown report or the Blade component |
bfsg-migrations |
the migrations, under their original file names | to keep the migrations in your repository |
php artisan vendor:publish --tag=bfsg-config
Quick start
# A page of this application: rendered in-process through the HTTP kernel, no web server needed php artisan bfsg:check /contact # Any page on the web php artisan bfsg:check https://example.com # The same, as JSON for a script, failing only on errors (the default) php artisan bfsg:check https://example.com --format=json > bfsg.json
The CLI output lists the findings per analyzer and ends with a summary line:
Checking http://localhost/contact
images (1)
[Error] WCAG 1.1.1 Image without text alternative (/produkt.jpg)
Suggestion: Add an alt attribute that describes the image, or alt="" if it is purely decorative
links (1)
[Warning] WCAG 2.4.4 Link text "hier klicken" does not describe the destination (/more)
Suggestion: Use link text that names the destination, or add an aria-label that does
2 findings (1 errors, 1 warnings, 0 notices), score 93 of 100, grade B
Failed: findings with severity Error or higher
bfsg:check
php artisan bfsg:check [options] [--] [<url>]
url is an absolute http/https URL or a path of this application (default /). Paths, and URLs on the origin of app.url (same scheme, host and port), are rendered in-process through the HTTP kernel of the running application: no web server, no network, and --as can act as a user. Every other URL is fetched over HTTP; redirects are followed (at most five), and same-origin <link rel="stylesheet"> files are inlined so the contrast analyzer sees them.
Exit codes
| Code | Meaning |
|---|---|
0 |
The threshold is met (no finding at or above --fail-on, score not below --min-score) |
1 |
The threshold is exceeded |
2 |
Operational error: unknown or invalid option, an option combination that would be ignored, fetch or login failure, a redirect to the login page, Playwright missing, tables missing for --save |
Status lines ("Checking …", warnings, the summary) go to stderr. With --format=json or --format=markdown stdout carries only the report, so php artisan bfsg:check / --format=json | jq .summary works; -q silences the status lines but never the report. When you call the command with Artisan::call(), the default buffered output has no stderr, so the status lines end up in the same buffer as the report: pass --output=<file> to get the report alone.
Options
Output and thresholds:
| Option | Default | Description |
|---|---|---|
--format= |
cli |
cli, json, markdown, html or pdf. html and pdf are always written to a file |
--output= |
Write the report to this file (not with cli). Without it, html/pdf go to bfsg.reporting.output_path |
|
--fail-on= |
error |
Exit 1 on findings of this severity or higher: error, warning, notice or none |
--min-score= |
Exit 1 when the score is below this value (0-100) | |
--detailed |
CLI output: also print element, selector and HTML snippet of every finding | |
--save |
Store the report in the database (needs php artisan migrate) |
Analyzers and language:
| Option | Description |
|---|---|
--only= |
Comma-separated analyzer keys to run, for example --only=images,forms |
--except= |
Comma-separated analyzer keys to skip |
--locale= |
Locale of messages and reports (en, de, or a locale your app provides). Default: bfsg.locale, then app.locale |
Fetching:
| Option | Description |
|---|---|
--insecure |
Do not verify TLS certificates. Only for hosts you control with self-signed certificates: with --insecure, credentials and tokens are sent without verifying the server |
--no-inline-css |
Do not inline same-origin stylesheets (also with --browser) |
--allow-login-page |
Analyze the page even when the URL redirected to the login page (otherwise exit 2) |
--login-url= |
Login page, absolute or relative to the checked site; with --sanctum it must be on the origin of the checked page. Default: bfsg.authentication.default_login_url |
Authentication (see Checking pages behind a login):
| Option | Description |
|---|---|
--as= |
For pages of this application: act as the user with this id or email (in-process) |
--guard= |
Guard used by --as |
--auth |
Log in first with a form login (--email, --password) |
--email=, --password= |
Credentials for --auth and --sanctum |
--username-field=, --password-field= |
Field names of the login form (default email, password) |
--json-auth |
With --auth: log in with a JSON request; a token in the answer becomes the bearer token |
--sanctum |
Log in through Laravel Sanctum (/sanctum/csrf-cookie, then the login URL) |
--bearer=, --jwt= |
Send Authorization: Bearer <token> |
--api-key=, --api-key-header= |
Send an API key header (default header X-API-Key) |
--session= |
Send an existing session cookie, given as name=value |
Browser (see Browser mode):
| Option | Default | Description |
|---|---|---|
--browser |
Render the page with Playwright before the analysis | |
--engine= |
chromium |
chromium, firefox or webkit |
--headless= |
true |
false shows the browser window |
--timeout= |
30000 |
One deadline in milliseconds for launch, navigation and --wait-for |
--wait-for= |
body |
CSS selector to wait for before the DOM is taken |
Options that would be silently ignored in a combination are an error (exit 2) instead: browser options without --browser, --browser with any login option, --insecure, --allow-login-page or --login-url, --as with a remote authentication option, --guard without --as, --email/--password/--username-field/--password-field without --auth or --sanctum, --json-auth without --auth, --api-key-header without --api-key, and --sanctum with a login URL on another origin than the checked page (Sanctum logs in on the checked site).
Examples
# Fail the build on warnings too, and on a score below 90 php artisan bfsg:check / --fail-on=warning --min-score=90 # Only contrast and forms, German messages, with element details php artisan bfsg:check /checkout --only=contrast,forms --locale=de --detailed # HTML report for stakeholders php artisan bfsg:check https://example.com --format=html --output=storage/app/bfsg/home.html # Keep the result for bfsg:history php artisan bfsg:check https://example.com --save
In CI
Paths are rendered in-process, so a CI job needs no web server:
- name: Accessibility check run: | php artisan migrate --force php artisan bfsg:check / --format=json --output=bfsg-home.json php artisan bfsg:check /contact --fail-on=warning
Checking pages behind a login
Pages of this application: --as=<id|email> renders the page in-process as that user (with --guard= for another guard). Nothing goes over the network and no password is needed.
php artisan bfsg:check /dashboard --as=admin@example.com
Remote pages (or pages of this application fetched over HTTP, which every remote authentication option forces):
# Form login: reads the CSRF token, posts the credentials, keeps the session cookie php artisan bfsg:check https://staging.example.com/dashboard --auth --email=qa@example.com --password=secret # Credentials from the environment (BFSG_AUTH_EMAIL / BFSG_AUTH_PASSWORD, or BFSG_AUTH_TOKEN as bearer token) php artisan bfsg:check https://staging.example.com/dashboard --auth # Sanctum SPA login, JSON login, tokens, an existing session php artisan bfsg:check https://app.example.com/dashboard --sanctum --email=qa@example.com --password=secret php artisan bfsg:check https://api.example.com/profile --auth --json-auth --email=qa@example.com --password=secret php artisan bfsg:check https://api.example.com/profile --bearer=token php artisan bfsg:check https://app.example.com/dashboard --session="laravel_session=eyJpdiI6..."
Without --email/--password in an interactive terminal, --auth asks for them. A failed login exits 2 with its cause (CSRF token rejected, invalid credentials, validation error, two-factor challenge, no session). A page that redirects to the login page exits 2 unless you pass --allow-login-page.
In CI, pass credentials as BFSG_AUTH_EMAIL, BFSG_AUTH_PASSWORD or BFSG_AUTH_TOKEN from your CI secrets rather than --password or --bearer on the command line (BFSG_AUTH_TOKEN is read with --auth, so use --auth instead of a bare --bearer): command-line arguments end up in the shell history, job logs and the process list. --session has no environment variable; pass it from a masked CI secret.
Credentials are bound to one origin (scheme, host and port): tokens, API keys and session cookies are only sent to the checked site (or, for a login token, the login site), and a redirect to another host, another port or from https to http drops them.
HTTP basic auth (a protected staging site): put the credentials in the URL. They become an Authorization: Basic header for that origin, also for the login requests of --auth/--sanctum, and are removed from every status line, report and stored URL.
php artisan bfsg:check "https://deploy:s3cret@staging.example.com/dashboard" --auth --email=qa@example.com --password=secret
There is only one Authorization header, so credentials in the URL cannot be combined with --bearer, --jwt, --api-key-header=Authorization, or a login that answers with a bearer token (--json-auth or --sanctum returning a token, BFSG_AUTH_TOKEN): these combinations exit 2. Pages of this application are rendered in-process and see no basic-auth header; for routes behind auth.basic use --as.
Browser mode for single-page apps
Pages that build their content with JavaScript (React, Vue, Inertia without SSR) look empty to a plain fetch. --browser renders them with Playwright first:
npm install --save-dev playwright
npx playwright install chromium
php artisan bfsg:check https://app.example.com/dashboard --browser --wait-for="#app main"
Same-origin stylesheets of the rendered page are inlined from the browser's CSSOM (so are <style> elements that CSS-in-JS libraries fill through insertRule), unless you pass --no-inline-css. --browser does not share a login and ignores TLS options, so it cannot be combined with the authentication options, --insecure, --allow-login-page or --login-url. See SPA-TESTING.md for setup, CI and troubleshooting.
bfsg:history
Stored reports (--save, the middleware, the MCP generate_report tool) can be listed, followed over time and cleaned up:
php artisan bfsg:history # the latest 20 reports php artisan bfsg:history --url=https://example.com/pricing --limit=50 php artisan bfsg:history --url=https://example.com/pricing --trend php artisan bfsg:history --cleanup --days=90 # delete reports older than 90 days
| Option | Default | Description |
|---|---|---|
--url= |
Only reports of this URL. It is compared in its stored form (without query string, fragment and credentials), so you can paste the URL you checked | |
--limit= |
20 |
Number of reports |
--trend |
The latest reports of --url in chronological order, with the score change |
|
--cleanup |
Delete reports older than --days |
|
--days= |
30 |
Age limit for --cleanup |
The command exits 1 with the php artisan migrate hint when the tables are missing, and when --trend is given without --url.
Middleware
The middleware analyzes HTML pages your application serves, after the response has been sent (terminate()), so visitors never wait for it. It is registered under the alias bfsg and is off until you enable it:
BFSG_MIDDLEWARE_ENABLED=true
Use it on some routes:
use Illuminate\Support\Facades\Route; Route::middleware('bfsg')->group(function () { Route::view('/', 'welcome'); Route::view('/contact', 'contact'); });
or on every web route in bootstrap/app.php:
<?php use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Middleware; use ItsJustVita\LaravelBfsg\Middleware\CheckAccessibility; return Application::configure(basePath: dirname(__DIR__)) ->withRouting(web: __DIR__.'/../routes/web.php') ->withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [CheckAccessibility::class]); }) ->create();
It analyzes successful GET responses with an HTML body and skips XHR, Livewire and Inertia requests, redirects, downloads, streamed responses, the paths in bfsg.middleware.ignored_paths, and the in-process requests of bfsg:check and the MCP server. For each page with findings it logs one line with the counts per severity (warning, or info when there are only notices) on bfsg.middleware.log_channel:
BFSG: 4 violations on https://example.com/contact {"errors":1,"warnings":2,"notices":1}
With bfsg.reporting.save_to_database every analyzed page is stored, clean pages too. URLs are logged and stored without their query string. With app.debug on, the analysis runs before the response is sent and adds an X-BFSG-Violations header with the number of findings. The middleware never breaks a page: failures are logged with Log::error.
Programmatic API
use ItsJustVita\LaravelBfsg\Facades\Bfsg; $result = Bfsg::analyze('<main><img src="hero.jpg"><a href="/more">click here</a></main>'); foreach ($result->all() as $violation) { echo $violation->severity->value.' '.$violation->rule.' '.$violation->key.': '.$violation->message().PHP_EOL; } echo count($result).' findings, accessible: '.($result->isAccessible() ? 'yes' : 'no').PHP_EOL;
error 1.1.1 images.missing_alt: Image without text alternative (hero.jpg)
warning 2.4.4 links.non_descriptive: Link text "click here" does not describe the destination (/more)
2 findings, accessible: no
Bfsg::analyze(string $html, array $options = []) returns an AnalysisResult. Options: url, locale, fragment (null detects: input without <html> is a fragment, and document-level checks such as the page title are skipped), ignoredSelectors (default bfsg.ignored_selectors).
AnalysisResult: all(), byAnalyzer(), forAnalyzer('images'), count(), countBySeverity() (['error' => …, 'warning' => …, 'notice' => …]), hasErrors(), isAccessible() (no errors and no warnings; notices do not count), analyzersRun(), url(), locale(), toArray(), and json_encode() support.
Each Violation is a readonly value object:
| Property | Example | Notes |
|---|---|---|
analyzer |
images |
registry key |
key |
images.missing_alt |
stable; match on this, never on the message |
severity |
Severity::Error |
Error, Warning or Notice |
rule |
1.1.1 |
primary WCAG success criterion; null only for non-WCAG findings (tag security) |
related |
['1.3.1'] |
further criteria |
tags |
['aaa'] |
best-practice, aaa, security, approximate |
element, selector, snippet |
img.hero, /html[1]/body[1]/img[1], <img src="hero.jpg" class="hero"> |
null for document-level findings |
params, meta |
['src' => 'hero.jpg'] |
message placeholders; analyzer extras such as a contrast ratio |
autoFixable |
false |
true only for mechanical fixes |
Methods: message(?string $locale = null), suggestion(?string $locale = null), fingerprint() (stable id from analyzer, key, rule and selector), toArray(?string $locale = null).
Run a subset, or check a document you already parsed:
use ItsJustVita\LaravelBfsg\Dom\HtmlDocument; use ItsJustVita\LaravelBfsg\Facades\Bfsg; $html = '<!DOCTYPE html><html lang="en"><head><title>Contact – Example</title></head><body><main><h1>Contact</h1></main></body></html>'; $formsOnly = Bfsg::only(['forms', 'input_purpose'])->analyze($html); $withoutContrast = Bfsg::except(['contrast'])->analyze($html, ['locale' => 'de']); $result = Bfsg::analyzeDocument(HtmlDocument::fromHtml($html), ['url' => 'https://example.com/contact']); echo Bfsg::isAccessible($html) ? 'accessible' : 'not accessible';
only() and except() return a copy and leave the application-wide registry untouched.
Custom analyzers
Extend BaseAnalyzer, implement inspect() and call report() for every finding:
namespace App\Bfsg; use ItsJustVita\LaravelBfsg\Analyzers\BaseAnalyzer; use ItsJustVita\LaravelBfsg\Severity; class MarqueeAnalyzer extends BaseAnalyzer { protected string $key = 'marquee'; protected string $description = 'Moving content in <marquee>'; protected array $rules = ['2.2.2']; protected function inspect(): void { foreach ($this->queryVisible('//marquee') as $element) { $this->report('moving_content', Severity::Error, '2.2.2', $element, ['text' => $this->text($element)]); } } }
Register it once, in the boot() method of a service provider:
use App\Bfsg\MarqueeAnalyzer; use ItsJustVita\LaravelBfsg\Facades\Bfsg; Bfsg::register('marquee', MarqueeAnalyzer::class);
From then on it runs everywhere: bfsg:check (also --only=marquee), the middleware, the MCP tools and Bfsg::analyze(). BaseAnalyzer provides query(), queryVisible() (skips hidden elements), isHidden(), name() (accessible name), text(), ownText() and isFragment(); report() derives element, selector and snippet from the element. A class that implements ItsJustVita\LaravelBfsg\Contracts\Analyzer (key(), analyze(HtmlDocument $document): array) works too.
Messages are translation keys (bfsg::violations.marquee.moving_content.message and .suggestion). Add them next to the package's own in lang/vendor/bfsg/en/violations.php (and de, …), otherwise the raw key is shown:
<?php return [ 'marquee' => [ 'moving_content' => [ 'message' => 'Moving content in <marquee>: ":text"', 'suggestion' => 'Remove the marquee, or give users a way to pause and stop it', ], ], ];
Reports and score
--format and ReportGenerator produce four formats: json, markdown, html (a self-contained page in the report locale that passes the package's own analyzers) and pdf (needs barryvdh/laravel-dompdf).
use ItsJustVita\LaravelBfsg\Facades\Bfsg; use ItsJustVita\LaravelBfsg\Reports\ReportGenerator; $result = Bfsg::analyze('<main><img src="hero.jpg"></main>', ['url' => 'https://example.com/']); $report = new ReportGenerator($result, 'de'); $json = $report->format('json')->render(); $html = $report->format('html')->render(); echo $report->score().' '.$report->grade();
saveTo($path) writes the rendered report and returns the path; defaultPath() is a file name under bfsg.reporting.output_path.
The JSON report is a stable contract. For https://example.com/pricing, a page with lang, a title, a heading and one image without alt:
{
"url": "https://example.com/pricing",
"package_version": "3.0.0",
"locale": "en",
"analyzed_at": "2026-09-24T10:00:00+00:00",
"analyzers": [
"images", "forms", "headings", "contrast", "aria", "links", "keyboard", "language",
"tables", "media", "semantic", "page_title", "input_purpose", "focus", "error_handling", "status_messages"
],
"summary": {"total": 1, "errors": 1, "warnings": 0, "notices": 0, "score": 95, "grade": "B", "accessible": false},
"violations": {
"images": [
{
"id": "8234b16bb4d47385726cc633887ae210ae453184",
"analyzer": "images",
"key": "images.missing_alt",
"severity": "error",
"rule": "1.1.1",
"related": [],
"tags": [],
"message": "Image without text alternative (hero.jpg)",
"suggestion": "Add an alt attribute that describes the image, or alt=\"\" if it is purely decorative",
"element": "img",
"selector": "/html[1]/body[1]/main[1]/img[1]",
"snippet": "<img src=\"hero.jpg\">",
"params": {"src": "hero.jpg"},
"meta": {},
"auto_fixable": false
}
]
}
}
Score: score = max(0, round(100 − Σ weight)) with the weights from bfsg.scoring.weights (default: error 5, warning 2, notice 0.5 per finding).
Grade: A+ from 95, A from 90, B+ from 85, B from 80, C+ from 75, C from 70, D from 60, F below. A page with at least one error gets at most B, with five or more errors at most D.
Localization
Messages, suggestions, the bfsg:check status lines and reports are available in English (en) and German (de); error messages and the output of bfsg:history are in English. The locale is, in this order: --locale / the MCP locale argument / Bfsg::analyze($html, ['locale' => 'de']), then bfsg.locale (BFSG_LOCALE), then app.locale. A configured locale without translations falls back to app.fallback_locale, then en; an explicit --locale or MCP locale without translations is an error.
To reword messages or add a language, publish the translations (php artisan vendor:publish --tag=bfsg-lang) and edit or copy lang/vendor/bfsg/<locale>/violations.php and report.php. A locale counts as available for --locale once lang/vendor/bfsg/<locale>/report.php exists.
Persistence
bfsg_reports holds one row per report (URL, score, grade, number of findings, metadata), bfsg_violations one row per finding (analyzer, key, severity, rule, message, suggestion, element, fingerprint, and a JSON context with selector, snippet, params, meta, related criteria and tags). The models are ItsJustVita\LaravelBfsg\Models\BfsgReport and BfsgViolation; ItsJustVita\LaravelBfsg\Persistence\ReportRepository::store() is the only writer.
use ItsJustVita\LaravelBfsg\Facades\Bfsg; use ItsJustVita\LaravelBfsg\Models\BfsgReport; use ItsJustVita\LaravelBfsg\Persistence\ReportRepository; $result = Bfsg::analyze('<main><img src="hero.jpg"></main>', ['url' => 'https://example.com/?utm_source=mail']); $report = app(ReportRepository::class)->store($result, ['source' => 'nightly']); $history = BfsgReport::forUrl('https://example.com/')->latest()->get();
URLs are stored without query string, fragment and credentials (at most 2048 characters); BfsgReport::forUrl() and bfsg:history --url normalise the URL you give the same way. Set bfsg.reporting.database.connection (BFSG_DB_CONNECTION) to keep the tables on another connection; the migrations follow it.
MCP server
The MCP server lets an AI assistant run the checks. It needs laravel/mcp:
composer require laravel/mcp
Register it with your client, for example Claude Code:
claude mcp add bfsg -- php artisan bfsg:mcp-server
or in .mcp.json in the project root:
{
"mcpServers": {
"bfsg": {
"command": "php",
"args": ["artisan", "bfsg:mcp-server"]
}
}
}
bfsg:mcp-server registers the server with laravel/mcp under the handle bfsg, so Mcp::local('bfsg', \ItsJustVita\LaravelBfsg\Mcp\BfsgMcpServer::class) in routes/ai.php together with php artisan mcp:start bfsg works as well. With Laravel Boost installed, the tools are added to Boost's MCP server automatically.
| Tool | Arguments | Result |
|---|---|---|
analyze_html |
html, locale |
the JSON report of the HTML |
analyze_url |
url (a URL, or a path of this application), locale |
the JSON report of the page |
check_contrast |
foreground, background |
ratio and AA/AAA pass flags |
list_analyzers |
key, class, description, WCAG criteria and enabled state of every analyzer | |
get_history |
url, limit |
stored reports |
get_report |
report_id |
one stored report with its findings |
generate_report |
url, format (json, markdown, html, pdf), locale, save |
the summary and the rendered report (a file path for pdf) |
Network access. analyze_url and generate_report fetch what the assistant asks for, so they are restricted:
bfsg.mcp.allowed_hosts: a list of hostnames. When set, the list replaces the public-host default: only the listed hosts are fetched (a public host that is not listed is refused), and every URL and every redirect hop must be on one of them. Listed hosts are trusted as they are, without the private-network check and address pinning below, so list only hosts whose DNS you control.- When it is
null(the default) or empty, any public host is allowed and a private-network guard refuses every non-public address: loopback, private and link-local ranges (including the cloud metadata address169.254.169.254), CGNAT, reserved ranges, IPv6 unique-local and site-local, and IPv6 forms that embed such an IPv4 address. Numeric host spellings (0x7f000001,127.1) are recognised, hosts that do not resolve are refused, and each request is pinned to the addresses that were checked, so DNS rebinding cannot redirect it. Pinning needs PHP's curl extension; without it such fetches fail. - Pages of this application (paths, or the exact origin of
app.url) are rendered in-process and exempt from the private-network guard; a remote page that redirects to the application is guarded like any other hop. Withallowed_hostsset, list the host ofapp.urlfor paths and pages of this application to work. - TLS verification follows
bfsg.mcp.verify_sslonly; the assistant cannot switch it off.
Blade component
<x-bfsg-accessible-image src="/img/team.jpg" alt="Our team of five in front of the office" width="600" /> <x-bfsg-accessible-image src="/img/lake.jpg" alt="Mountain lake at sunrise" caption="Photo: Anna Example" /> <x-bfsg-accessible-image src="/img/divider.svg" :decorative="true" />
The component renders a bare <img> with your extra attributes, or a <figure> with <figcaption> when caption is given. It throws an InvalidArgumentException when the alt text is empty and the image is not marked :decorative="true", so a content image can never end up with alt="" by accident. Decorative images get alt="" aria-hidden="true" (a role or aria-hidden you pass is dropped); a decorative image cannot have a caption. loading is only set when you pass it (loading="lazy"). Pass decorative as a boolean binding (:decorative="true"); the string decorative="false" would be true in PHP.
Configuration reference
All settings live in config/bfsg.php (php artisan vendor:publish --tag=bfsg-config).
| Key | Env | Default | Description |
|---|---|---|---|
bfsg.compliance_level |
BFSG_LEVEL |
AA |
AA (the BFSG requirement) or AAA (contrast 7:1 / 4.5:1; AAA-only findings are notices tagged aaa) |
bfsg.locale |
BFSG_LOCALE |
null |
Locale of messages and reports; null = app.locale |
bfsg.checks |
all true |
Analyzer key => enabled; the registry is built from it once per application | |
bfsg.ignored_selectors |
chat widget selectors | CSS selectors removed before the analysis (third-party widgets), in every entry point | |
bfsg.scoring.weights |
error 5, warning 2, notice 0.5 |
Score deduction per finding | |
bfsg.fetch.timeout |
BFSG_FETCH_TIMEOUT |
30 |
HTTP timeout in seconds |
bfsg.fetch.verify_ssl |
BFSG_VERIFY_SSL |
true |
TLS verification; only an explicit false turns it off (--insecure per run) |
bfsg.fetch.user_agent |
laravel-bfsg/3.0 (+https://github.com/itsjustvita/laravel-bfsg) |
User agent of every request | |
bfsg.fetch.inline_stylesheets |
BFSG_INLINE_CSS |
true |
Inline same-origin stylesheets (fetch and --browser) |
bfsg.fetch.max_stylesheets |
5 |
Stylesheets inlined per page | |
bfsg.fetch.max_stylesheet_bytes |
524288 |
Largest stylesheet inlined | |
bfsg.authentication.default_login_url |
/login |
Login page for --auth, and the page a redirect is recognised as "login page" by |
|
bfsg.middleware.enabled |
BFSG_MIDDLEWARE_ENABLED |
false |
Switches the middleware on |
bfsg.middleware.log_violations |
true |
Log one line per page with findings | |
bfsg.middleware.log_channel |
null |
Log channel; null = the default channel |
|
bfsg.middleware.ignored_paths |
admin/*, api/*, _debugbar/*, livewire/*, telescope/*, horizon/*, reset-password/*, password/reset/* |
Paths the middleware skips (Request::is() patterns) |
|
bfsg.reporting.save_to_database |
BFSG_SAVE_TO_DB |
false |
Middleware: store every analyzed page |
bfsg.reporting.database.connection |
BFSG_DB_CONNECTION |
null |
Connection of the report tables; null = default |
bfsg.reporting.output_path |
storage/app/bfsg-reports |
Directory of html/pdf reports written without --output |
|
bfsg.mcp.allowed_hosts |
null |
Hosts the MCP tools may fetch; null = any public host (see MCP server) |
|
bfsg.mcp.verify_ssl |
true |
TLS verification of the MCP tools; only an explicit false turns it off |
Analyzers
| Key | Checks | WCAG 2.1 |
|---|---|---|
images |
Text alternatives for images, image inputs, image maps and SVG | 1.1.1 |
forms |
Labels of form controls, names of buttons, radio groups, required fields | 4.1.2, 1.3.1, 3.3.2 |
headings |
Heading hierarchy and heading text | 1.3.1, 2.4.6 |
contrast |
Colour contrast of text (inline styles, <style> and inlined stylesheets, custom properties, oklch()) |
1.4.3, 1.4.6 |
aria |
ARIA roles, required and supported states, ID references, aria-hidden on focusable elements |
4.1.2, 1.3.1, 4.1.1 |
links |
Link names, link purpose, new windows, downloads, rel="noopener" |
2.4.4, 4.1.2, 3.2.5 |
keyboard |
Keyboard operability, skip links, tab order, dialogs | 2.1.1, 2.4.1, 2.4.3, 4.1.2 |
language |
Language of the page and of parts | 3.1.1, 3.1.2 |
tables |
Data table headers, scope, captions, header references | 1.3.1 |
media |
Captions, audio description, autoplay and controls of audio, video and embeds | 1.2.1, 1.2.2, 1.2.5, 1.4.2, 2.1.1, 2.2.2, 4.1.2 |
semantic |
Landmarks and document structure | 1.3.1, 2.4.1, 4.1.2 |
page_title |
Presence and quality of the page title | 2.4.2 |
input_purpose |
autocomplete on personal data fields |
1.3.5 |
focus |
Removed focus outlines without replacement | 2.4.7 |
error_handling |
Error identification in forms | 3.3.1 |
status_messages |
Live regions for status messages | 4.1.3 |
Severity policy: error is a definite AA failure detectable in the markup, warning a likely failure that needs a manual check (and contrast results that are approximate), notice a best practice, an AAA criterion or something that cannot be verified statically. Every finding points at one element, or at the document.
Limitations
- The analysis works on HTML and CSS, not on a rendered page: layout, JavaScript behaviour, focus order at runtime and screen-reader output are not tested.
--browseradds the JavaScript-built DOM, not interaction. - The accessible-name computation covers the common cases (
aria-labelledby,aria-label, native labels, alt texts, subtree text,title); it is not a complete implementation of the W3C algorithm. - Contrast is measured from inline styles,
<style>elements and inlined same-origin stylesheets with a documented CSS subset (no:hover,:nth-child, pseudo-elements). Background images, gradients and unresolved custom properties make a measurement approximate: such failures are warnings with the tagapproximate.
Development
composer test # PHPUnit composer lint # Pint, check only composer fix # Pint
tests/Live/ contains a smoke test that installs the package into a fresh Laravel application and exercises the commands, the middleware, the browser mode and the MCP server end to end; CI runs it on every push. See CONTRIBUTING.md.
Roadmap
Planned for the next releases: an ignore mechanism for known findings, diff reports between two runs, SARIF output for code scanning, axe-core in browser mode, Livewire and Filament integration, and a GitHub Action.
BFSG
The Barrierefreiheitsstärkungsgesetz implements the European Accessibility Act in Germany and applies since 28 June 2025 to many digital products and services, including e-commerce. Its technical reference is EN 301 549, which for the web points to WCAG 2.1 level AA.
Changelog, security, license
See CHANGELOG.md for the changes of every release. Please report security issues to hello@itsjustvita.com instead of the issue tracker. The package is written by Vitalis Feist-Wurm and released under the MIT license.