A11y Gate: The Accessibility Gate for Statamic

An addon that refuses to publish an entry that fails accessibility checks.

0.10.2

September 16th, 2026

Security

A finding's pointer was drawn as HTML in the control panel, and a heading could carry a script. Every finding names the part of the page to go and fix: a duplicate heading's text, an image's src, a link's words. That is page content, and the panel drew it with a component that renders through innerHTML. A heading written on a page as <img src=/x.png onerror=...> is escaped on the front end and was never a risk there, but it reaches the panel decoded, and the panel ran it in the control panel of whoever pressed Check on that entry or was refused a save of it. Anyone who can put a heading on a gated entry could plant it, and it fired for an admin. Confirmed on a live control panel, not reasoned from the code. Every string the panel draws now goes through Vue's escaping, page-derived or not, so the same heading draws as the characters it is.

What to do about it: upgrade, and republish the addon's assets if your deploy does not (php artisan vendor:publish --tag=statamic-a11y-gate --force, or php artisan statamic:install). 0.10.1 and earlier are affected.

Fixed

A refused save now takes the author to the panel. Below the width where Statamic folds the sidebar into its own tab (tablets and small laptops), an author who pressed Save & Publish on the main tab got only the corner toast "The given data was invalid", while the refusal itself was drawn in full on a tab they were not looking at. The panel now asks Statamic's own reveal to switch to it and scroll it into view, the same call the save pipeline makes. Guarded, so a build without the reveal draws the alert exactly where it did before.

The Tools page drew its lists without bullets. It carried list-disc, and the control panel's compiled stylesheet does not contain that class, so it was dropped without a word and the lists drew flat. The bullets are an inline style now, and a test reads the build and checks every class the addon writes against it, so a class the panel cannot draw fails before it ships.

Two instructions that were wrong when followed. The 0.8.0 note to republish assets named --tag=laravel-assets, which publishes nothing for this addon; it names the addon's own tag now. And the README said a stock site cannot re-save its home page for want of an h1, which stopped being true when statamic/statamic 6.5 redesigned the welcome page; it now says which sites it is about.

Changed

The Tools page no longer says "Anything that would fail WCAG 2.2 AA stops the publish". Read on its own that is the completeness claim this addon refuses to make everywhere else, two paragraphs above the line saying colour contrast is not checked. It says "Anything it finds" now. Render-failure messages no longer read "not found..": Laravel's own message ends in a full stop and every quote added its own.

The auto-placed Accessibility panel is no longer offered as a column on the entries listing. The field stores nothing, so the column could only ever be empty. It says listable: false now.

Added

The README says how to install the addon and where to report a problem. An ## Installing section with the composer require line, the Statamic and PHP floors, what ext-intl adds, the config publish tag, and the two files an uninstall can leave behind; and a ## Support section naming the public issue tracker as the one channel, answered by one person with no response-time promise. A support key in composer.json so Packagist and the Marketplace show the same.

0.10.1

September 15th, 2026

Changed, in the control panel only

The addon is "A11y Gate" everywhere it names itself. The Addons page, the Tools nav, the utility page header, the report-mode message and the settings copy said "Accessibility Gate", while the Marketplace listing, the README and A11y Docs beside it in the same list already said "A11y". One name now. Handles, permissions, config keys and the package name are unchanged, so nothing has to be edited and nobody loses access.

0.10.0

September 15th, 2026

Changed

A page that answers with a redirect is no longer refused. A page for signed-in visitors opens with {{ redirect }} when nobody is, and the gate is nobody. Since 0.9.2 the gate said so accurately ("the page came back as HTTP 302, sending visitors to /login") and refused the save anyway, as a check that could not run. That made a learning site's account page unpublishable, over a page that had done exactly what it was written to do.

The page is now saved, and the panel says "Not checked: this page sends visitors to /login, so there was no page to check". The site scan lists it under a new "Not checked" heading and does not fail the build on it. In both places the wording says what a signed-in visitor sees has not been checked, because it has not, and a report that dropped the page would be claiming coverage it did not have.

A redirect back to the page's own address is a loop, which is a broken template rather than a sign-in wall. That is still refused and still fails the build, and has its own test so the two cannot drift into one.

This is a refusal turned into a pass-through, which is the one kind of change this addon is most suspicious of. The reasoning and the alternatives turned down are in the decision log.

0.9.2

September 15th, 2026

Fixed

A page that redirects is reported as a redirect, not as a crash. A page for signed-in visitors that opens with {{ redirect }} sends the scan, which is nobody, to the login page. Statamic's tag redirects by throwing the response, and the renderer caught that with everything else and reported "the page threw while rendering: HttpResponseException". The page had not crashed; it had done exactly what it was written to do, and the line named nothing a site owner could act on. It now reads "the page came back as HTTP 302, sending visitors to /login". The page is still listed as one the scan could not read, and a save of it is still refused, because neither has changed: nobody has checked that page. What changed is that the report now says why.

0.9.1

September 15th, 2026

Changed

Runs on PHP 8.2 and newer. The manifest said 8.4 and nothing in the code needed it: the floor was the version the project started on, not one it used. The real floor is Laravel's, 8.2 on Laravel 12, and that is now what is declared. A job in CI installs the oldest version of everything the manifest allows, on 8.2, and runs the suite there, so the floor is a tested fact rather than a number in a file.

PHP 8.2 leaves security support at the end of December 2026. That is the date to raise this to 8.3, and it is in the decision log so nobody has to remember.

0.9.0

September 10th, 2026

Added

A panel block may name a refusalKey. Statamic puts a 422's errors onto the publish container exactly as they were sent, and hard-codes the toast to "The given data was invalid", so an addon that refuses a save has nowhere of its own to say why. Keying the refusal to a blueprint field puts it under that field, where it reads as a fault in the field: A11y Docs was keying "this entry links to a document nobody can read" to title. A block that names a key has whatever is under that key drawn in the block instead, in place of its standing lines and above its link. The key has to be shaped like a handle.

PanelExtensions::supports() answers whether this version draws a given capability, today only refusalKey. A provider ships on its own release cycle, and naming a key at a gate too old to read one sends the refusal somewhere nothing draws without failing: the save is still refused and the author is never told why.

0.8.1

September 10th, 2026

Fixed, where the addon was saying something untrue

The all-clear badge now says what it was an all-clear about. It reads "Nothing to fix in the page itself". This gate checks the rendered HTML and cannot open a linked document, so a PanelExtensions block underneath it can report a PDF a screen reader cannot open on the same screen. A flat "Nothing to fix" above that was two verdicts with nothing to tell them apart. The wording does not depend on what else is installed: a badge that means one thing alone and another in company would be harder to trust, not easier.

0.8.0

September 7th, 2026

Added

A link under each finding to the W3C's page on the criterion it cites. "WCAG 1.1.1 Non-text Content" under an image finding opens the Understanding page for it, in a new tab that is announced. Underlined and coloured for both themes, because the control panel resets anchors to look like text. A house rule such as "Heading structure" cites no criterion and has no link. The check endpoint sends the reference as reference on each finding, null for a house rule.

A block in the panel may carry a mark. PanelExtensions blocks take an optional mark of a url and an alt, drawn above the block's heading at about the height of a line of text, so a companion addon's block can be recognised as the site owner's own. The gate holds no branding of its own and gains no setting: it draws what a provider hands it. A mark with no words is left out, and so is an address that is not a path, an http or https URL, or a data:image/ value. A block reporting a problem wears no mark.

What to do about it: republish the addon's assets if your deploy does not (php artisan vendor:publish --tag=laravel-assets --force).

0.7.0

September 2nd, 2026

One addition and nothing removed.

A seam in the panel for another addon. PanelExtensions::register() takes a callable that, given the entry, returns a block to draw beneath the gate's own result on the entry screen: a heading, plain lines, an optional link. Nothing about the checks or the refusal changes: a provider cannot add a finding, alter one, or affect a save, and one that throws is drawn as a warning rather than dropped. Accessibility Report uses it to show a page's open issues from the last scan.

What to do about it: nothing. Without a provider registered the panel is what it was.

0.6.0

August 19th, 2026

Removed, and it can break a site

ReadingLevel::grade() is gone, along with the three private helpers behind it.

It is very unlikely you were calling it. It was never called by the addon itself, in any commit, and it was named in no README line, no config comment and no decision entry, so the only way to find it was to read src/. There is no Antlers tag or modifier for it, so no template could have reached it.

If you were calling it, from a listener or a service that stamps data-a11y-reading-grade on your plain-language summaries, that call is now a fatal error. Compute the grade yourself instead. The formula the threshold is calibrated against is Flesch-Kincaid:

0.39 x (words per sentence) + 11.8 x (syllables per word) - 15.59

ReadingLevel::PLAIN_MAX_GRADE stays, and is still the number the check compares your stamped grade against.

Why it was removed rather than documented: its syllable count stripped every character outside a-z, so a word in Japanese, Arabic or Russian counted as one syllable and the grade collapsed into a function of sentence length alone. Real passages in those scripts scored 0.0 and 0.1 against a threshold of 9, which is a confident-looking pass for text the formula has no opinion about. Publishing that as a supported API was not something this addon could do honestly, and leaving it in place unused and undeclared was the ambiguity that got it noticed.

There is deliberately no deprecation shim. A method that still returns 0.1 for Arabic is worse kept than removed.

Fixed, where the addon was saying something untrue

  • A page whose only media was a figure was told to confirm that its video had captions. It has no video. A page that did carry the markup for captions, transcripts, figure text and footnotes was told that captions "are only checked on sites that mark them up, and this one does not", in the same report as a finding only that markup could have produced.
  • The Accessibility Gate page under Tools said there were three exceptions that do not stop a publish. There are four: link text that is vague rather than plainly wrong was missing. The same page said the entry "is not saved" without mentioning that report mode is a setting on the screen it sends you to, gave 24 pixels as the touch target minimum without saying a config file raises it to 44, and said "embedded videos" where the rule checks every frame, maps and forms included.
  • In report mode the log said "This entry was not saved" about an entry that was saved.
  • The site-wide scan counted a page once per finding rather than once. A page with three identical "Read more" links reported as three pages, stopped naming the page it was on, and on a small site could claim "every page", which is the line that sends somebody to rewrite a template.

Changed, with no difference to what is reported

  • Severity now lives with the rule rather than being passed at each of the eighteen places a rule is raised. A rule can no longer be raised at two different severities, and a typo can no longer turn a refusal into a warning.
  • The opt-in setting is read once, by the checker. Every check now always answers how much of the page it could see, so a check can no longer drop out of the count and make "4 of 5 checks ran" read as "4 of 4".
  • AccessibilityStandard is a backed enum. It used to be three fields anyone could set, so a label claiming AAA could sit over a minimum of 7 pixels.
  • The corpus refuses to run against a fixture it cannot read, and pins its cases by name rather than by counting them.

Verified across all 28 corpus fixtures, before and after, including every message, call to action and pointer: no finding changed.

0.5.4

November 29th, -0001

  • N/A Changelog not available.

0.5.3

November 29th, -0001

  • N/A Changelog not available.