Skills DirectorySkills Directory
SkillsLearnSecurityCategoriesDocsBlogPro
Sign InSubmit Skill
Skills Directory

Security-tested agent skills for Claude, coding agents, and AI workflows.

Directory

  • Browse Skills
  • All Skills A–Z
  • Claude Skills
  • Claude Code Skills
  • Agent Skills
  • Categories
  • Authors
  • Submit a Skill

Learn

  • Learn Hub
  • Install Claude Skills
  • Write SKILL.md
  • Skills vs MCP
  • Directories Compared

Security

  • Security
  • Methodology
  • Secure Claude Skills
  • Security Badges
  • Chrome Extension
  • Skill Manager

Company

  • About
  • Community
  • Blog
  • API Docs
  • Advertise

2026 Skills Directory. All rights reserved.

ProTermsPrivacyRefunds
Back to skills

Wcag Form Accessibility Php Laravel

ASecurity

Implements WCAG 2.2 AA accessible form patterns in PHP/Laravel using semantic HTML, aria-describedby error linking, fieldset/legend grouping, focus management, and server-rendered form practices for guaranteed accessibility compliance.

6 stars
0 votes
0 copies
0 views
Added 9/25/2026
developmentjavascriptrustgojavaphpbashawstestinggitdevops

Works with

cli

Security Analysis

A100/100

Scanned 9/25/2026

$npx -y skills add paulpas/agent-skill-router --skill wcag-form-accessibility-php-laravel --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Wcag Form Accessibility Php Laravel?

Add the live security badge to your README — it updates automatically with every re-scan.

Security grade badge for Wcag Form Accessibility Php Laravel
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/paulpas-wcag-form-accessibility-php-laravel/badge)](https://www.skillsdirectory.com/skills/paulpas-wcag-form-accessibility-php-laravel)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
Files
SKILL.md
---
name: wcag-form-accessibility-php-laravel
description: Implements WCAG 2.2 AA accessible form patterns in PHP/Laravel using semantic HTML, aria-describedby error linking, fieldset/legend grouping, focus management, and server-rendered form practices for guaranteed accessibility compliance.
license: MIT
compatibility: opencode
metadata:
  version: "1.0.0"
  domain: coding
  role: implementation
  scope: implementation
  output-format: code
  triggers: WCAG form accessibility, Laravel form validation, aria-describedby, accessible form groups, Blade form patterns, form error handling, focus management forms, Livewire accessibility
  related-skills: []
  archetypes: tactical
  anti_triggers: brainstorming, vague ideation
  response_profile:
    verbosity: low
    directive_strength: high
    abstraction_level: operational
---

# WCAG 2.2 AA Accessible Form Patterns in PHP/Laravel

This skill enables server-rendered form patterns that achieve WCAG 2.2 AA compliance through semantic HTML, proper label association, error message linking, and focus management. Load this skill when implementing or reviewing forms in Laravel, plain PHP, or WordPress plugins.

## When to Use

Use this skill when:

- Building HTML forms in Laravel Blade or plain PHP templates
- Implementing form validation with error messages and accessibility requirements
- Creating fieldsets with radio buttons or checkbox groups
- Implementing error summary pages with focus restoration
- Migrating forms from accessibility violations to WCAG 2.2 AA compliance
- Reviewing form markup for WCAG 1.3.1 (Info & Relationships), 3.3.1 (Error Identification), 2.4.3 (Focus Order), 4.1.2 (Name, Role, Value)
- Integrating laravel-blade-ally linter into CI/CD pipelines
- Enhancing WordPress Contact Form 7 or Gravity Forms with accessibility hooks
- Building Livewire components with accessible form interactions

## When NOT to Use

Avoid this skill for:

- Client-side form libraries that abstract HTML generation (FormKit, Nuxt forms) — use framework-specific accessibility guidance
- Complex SPA form builders without server-side rendering — client-side routing breaks focus management
- Form components already meeting AAA standards or exceeding project requirements
- Simple, single-input forms where WCAG compliance overhead outweighs benefit (consider: any public form should be accessible)

---

## TL;DR Checklist

- [ ] Every input has a `<label>` with matching `for` attribute (WCAG 1.3.1)
- [ ] Error messages linked via `aria-describedby` on input element (WCAG 3.3.1)
- [ ] Radio/checkbox groups wrapped in `<fieldset>` with `<legend>` (WCAG 1.3.1)
- [ ] Focus order follows logical tab sequence (WCAG 2.4.3)
- [ ] Error summary with focus management on page reload (WCAG 3.3.1, 3.3.7)
- [ ] Form keep-alive prevents data loss on validation error (WCAG 3.3.7)
- [ ] laravel-blade-ally linter enabled in CI/CD pipeline (gates accessibility)
- [ ] Livewire components announce errors via aria-live regions
- [ ] WordPress plugin hooks extend accessibility without forking

---

## Core Workflow

1. **Establish Semantic Structure** — Use `<form>`, `<fieldset>`, `<legend>`, and semantic input types (email, tel, date). **Checkpoint:** Validate form element contains all inputs; fieldsets group related inputs.

2. **Link Labels to Inputs** — Every input must have a `<label>` with `for` attribute matching the input `id`. **Checkpoint:** Run `form.elements[name].labels[0].htmlFor` in browser console; should return the input's id.

3. **Associate Error Messages** — Link error messages to inputs via `aria-describedby`. Error message element must have a unique `id` matching the input's `aria-describedby` value. **Checkpoint:** Inspect `input.getAttribute('aria-describedby')` and verify corresponding element exists and contains error text.

4. **Implement Error Summary** — Create error summary page with all validation errors linked to problem inputs. Focus first error element on page load. **Checkpoint:** Tab to each error link; verify it jumps to the problematic input, which receives focus and has error message visible.

5. **Manage Focus State** — On form submission failure, restore focus to first error input or error summary. Preserve form data via Laravel's `old()` helper or Livewire's `wire:model`. **Checkpoint:** Submit invalid form; verify focus moves to error summary or first problematic input.

6. **Test with Screen Readers** — Use NVDA (free), JAWS, or VoiceOver to verify form is announced correctly. Screen reader should announce label, required status, and error message for each input. **Checkpoint:** Load page with screen reader; listen for full form instruction before attempting interaction.

7. **Lint Blade Templates** — Integrate laravel-blade-ally into CI/CD to catch accessibility violations in pull requests. Fails on missing labels, unlinked errors, and invalid ARIA attributes. **Checkpoint:** Run `php artisan blade-ally` before merge; zero warnings.

---

## Implementation Patterns

### Pattern 1: Basic Label-to-Input Association (WCAG 1.3.1)

The foundation of form accessibility is connecting labels to inputs. Every input must have a corresponding `<label>` element with a `for` attribute matching the input's `id`.

**Blade Template:**
```blade
<form action="{{ route('contact.store') }}" method="POST">
    @csrf
    
    <div class="form-group">
        <label for="email">Email Address <span aria-label="required">*</span></label>
        <input 
            type="email" 
            id="email" 
            name="email" 
            required 
            aria-required="true"
            value="{{ old('email') }}"
            @error('email') aria-invalid="true" @enderror
        />
    </div>

    <button type="submit">Submit</button>
</form>
```

**Key Points:**
- `id="email"` on input matches `for="email"` on label
- `aria-required="true"` announces required status to assistive technology
- `aria-invalid="true"` set when validation error exists
- `value="{{ old('email') }}"` preserves user input on validation failure (WCAG 3.3.7)
- `<span aria-label="required">*</span>` ensures asterisk is announced as "required"

**Common Pitfall — Missing for attribute:**
```blade
<!-- ❌ BAD — label not linked to input -->
<label>Email Address</label>
<input type="email" name="email" />

<!-- ✅ GOOD — label linked via for attribute -->
<label for="email">Email Address</label>
<input type="email" id="email" name="email" />
```

### Pattern 2: Error Messages with aria-describedby (WCAG 3.3.1)

Error messages must be associated with their input using `aria-describedby`. The error message element needs a unique `id` that the input references.

**Blade Template with Laravel Validation Errors:**
```blade
<form action="{{ route('register.store') }}" method="POST">
    @csrf
    
    <div class="form-group">
        <label for="password">Password <span aria-label="required">*</span></label>
        <input 
            type="password" 
            id="password" 
            name="password" 
            required 
            aria-required="true"
            @error('password')
                aria-describedby="password-error"
                aria-invalid="true"
            @enderror
        />
        
        @error('password')
            <div id="password-error" role="alert" class="error-message">
                {{ $message }}
            </div>
        @enderror
        
        <!-- Help text supplementing error message -->
        <div id="password-help" class="help-text">
            Minimum 8 characters, at least one uppercase letter and one number.
        </div>
    </div>

    <button type="submit">Create Account</button>
</form>
```

**Key Points:**
- `aria-describedby="password-error"` links input to error message element
- Error message element has `id="password-error"` matching aria-describedby value
- `role="alert"` announces error immediately to screen readers
- Help text in separate element prevents duplicate announcements
- When there's no error, `aria-describedby` is not set (only set when error exists)

**PHP Controller Example:**
```php
<?php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Validation\Rules\Password;

class RegisterController extends Controller
{
    public function store(Request $request)
    {
        // Guard clause: validate input at boundary
        $validated = $request->validate([
            'email' => 'required|email|unique:users',
            'password' => ['required', Password::min(8)
                ->mixedCase()
                ->numbers()],
        ]);

        // Data now safely parsed and trusted
        $user = User::create($validated);

        return redirect()->route('register.success');
    }
}
```

**Multi-Error Pattern — Multiple Descriptions:**
```blade
<input 
    type="password" 
    id="password" 
    name="password" 
    aria-describedby="password-error password-help"
    @error('password') aria-invalid="true" @enderror
/>

@error('password')
    <div id="password-error" role="alert">{{ $message }}</div>
@enderror

<div id="password-help" class="help-text">
    8+ chars, uppercase, number required.
</div>
```

### Pattern 3: Fieldset and Legend for Radio/Checkbox Groups (WCAG 1.3.1)

Radio buttons and checkboxes must be grouped using `<fieldset>` with a `<legend>` describing the group. Individual inputs within the group still need labels.

**Blade Template — Radio Group Example:**
```blade
<fieldset class="form-fieldset">
    <legend class="form-legend">
        How would you like to be contacted? 
        <span aria-label="required">*</span>
    </legend>
    
    <div class="radio-group">
        <div class="radio-option">
            <input 
                type="radio" 
                id="contact-email" 
                name="contact_method" 
                value="email" 
                required
                @checked(old('contact_method') === 'email')
            />
            <label for="contact-email">Email</label>
        </div>
        
        <div class="radio-option">
            <input 
                type="radio" 
                id="contact-phone" 
                name="contact_method" 
                value="phone"
                @checked(old('contact_method') === 'phone')
            />
            <label for="contact-phone">Phone</label>
        </div>
        
        <div class="radio-option">
            <input 
                type="radio" 
                id="contact-sms" 
                name="contact_method" 
                value="sms"
                @checked(old('contact_method') === 'sms')
            />
            <label for="contact-sms">SMS</label>
        </div>
    </div>
    
    @error('contact_method')
        <div id="contact-method-error" role="alert" class="error-message">
            {{ $message }}
        </div>
    @enderror
</fieldset>
```

**Key Points:**
- `<fieldset>` wraps entire group
- `<legend>` describes the group purpose
- Each radio input has unique `id` and matching label `for` attribute
- Error message applies to fieldset, not individual inputs
- `@checked()` preserves selection on validation failure

**Checkbox Group Pattern:**
```blade
<fieldset class="form-fieldset">
    <legend class="form-legend">
        Select your interests 
        <span aria-label="required">*</span>
    </legend>
    
    <div class="checkbox-group">
        @foreach(['web' => 'Web Development', 'mobile' => 'Mobile Apps', 'devops' => 'DevOps'] as $value => $label)
            <div class="checkbox-option">
                <input 
                    type="checkbox" 
                    id="interest-{{ $value }}" 
                    name="interests[]" 
                    value="{{ $value }}"
                    @checked(in_array($value, (array)old('interests', [])))
                />
                <label for="interest-{{ $value }}">{{ $label }}</label>
            </div>
        @endforeach
    </div>
    
    @error('interests')
        <div id="interests-error" role="alert" class="error-message">
            {{ $message }}
        </div>
    @enderror
</fieldset>
```

**Common Pitfall — Missing Fieldset:**
```blade
<!-- ❌ BAD — no fieldset, screen reader announces three separate inputs -->
<label><input type="radio" name="contact" value="email" /> Email</label>
<label><input type="radio" name="contact" value="phone" /> Phone</label>
<label><input type="radio" name="contact" value="sms" /> SMS</label>

<!-- ✅ GOOD — fieldset groups inputs and announces shared context -->
<fieldset>
    <legend>How to contact you?</legend>
    <label><input type="radio" name="contact" value="email" /> Email</label>
    <label><input type="radio" name="contact" value="phone" /> Phone</label>
    <label><input type="radio" name="contact" value="sms" /> SMS</label>
</fieldset>
```

### Pattern 4: Error Summary with Focus Management (WCAG 3.3.1, 3.3.7)

On form submission failure, display an error summary at the top of the page with focus restoration to help users correct mistakes quickly.

**Blade Template with Error Summary:**
```blade
<form action="{{ route('checkout.process') }}" method="POST">
    @csrf
    
    @if ($errors->any())
        <div role="region" aria-labelledby="error-summary-heading" class="error-summary">
            <h2 id="error-summary-heading">Please correct the following errors:</h2>
            <ul>
                @foreach ($errors->all() as $error)
                    <li>
                        <a href="#{{ $error->get('key') }}" class="error-link">
                            {{ $error }}
                        </a>
                    </li>
                @endforeach
            </ul>
        </div>
    @endif

    <div class="form-group">
        <label for="billing-name">Full Name <span aria-label="required">*</span></label>
        <input 
            type="text" 
            id="billing-name" 
            name="name" 
            required 
            value="{{ old('name') }}"
            @error('name') aria-invalid="true" aria-describedby="name-error" @enderror
        />
        @error('name')
            <div id="name-error" role="alert" class="error-message">{{ $message }}</div>
        @enderror
    </div>

    <div class="form-group">
        <label for="billing-address">Address <span aria-label="required">*</span></label>
        <input 
            type="text" 
            id="billing-address" 
            name="address" 
            required 
            value="{{ old('address') }}"
            @error('address') aria-invalid="true" aria-describedby="address-error" @enderror
        />
        @error('address')
            <div id="address-error" role="alert" class="error-message">{{ $message }}</div>
        @enderror
    </div>

    <button type="submit" id="submit-btn">Complete Purchase</button>
</form>
```

**PHP Controller with Focus Restoration:**
```php
<?php
namespace App\Http\Controllers;

use Illuminate\Http\Request;

class CheckoutController extends Controller
{
    public function process(Request $request)
    {
        // Parse inputs at boundary; fail loud on validation error
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'address' => 'required|string|max:500',
            'email' => 'required|email',
        ]);

        // Data is now trusted; proceed with business logic
        $order = Order::create($validated);

        return redirect()->route('checkout.success', $order);
    }
}
```

**JavaScript Focus Restoration (Blade snippet):**
```blade
@if ($errors->any())
    <script>
        // Focus first error's input on page load
        const firstErrorInput = document.querySelector('[aria-invalid="true"]');
        if (firstErrorInput) {
            firstErrorInput.focus();
        }
        
        // Alternative: focus error summary heading
        // const errorSummary = document.getElementById('error-summary-heading');
        // if (errorSummary) {
        //     errorSummary.focus();
        //     errorSummary.tabIndex = -1; // Make focusable
        // }
    </script>
@endif
```

### Pattern 5: Livewire Form Component with Accessibility (Real-Time Validation)

Livewire components enable real-time form validation while maintaining accessibility through aria-live regions and focus management.

**Livewire Component Class:**
```php
<?php
namespace App\Livewire\Forms;

use Livewire\Component;
use Livewire\Attributes\Validate;

class SubscriptionForm extends Component
{
    #[Validate('required|email|unique:subscribers')]
    public string $email = '';

    #[Validate('required|min:3')]
    public string $name = '';

    public bool $hasSubmitted = false;

    public function updated($propertyName)
    {
        // Real-time validation on field blur
        $this->validateOnly($propertyName);
    }

    public function subscribe()
    {
        // Guard clause: validate at boundary
        $validated = $this->validate();

        Subscriber::create($validated);

        $this->reset();
        $this->hasSubmitted = true;
    }

    public function render()
    {
        return view('livewire.subscription-form');
    }
}
```

**Livewire Blade Component Template:**
```blade
<form wire:submit="subscribe" class="subscription-form">
    <h2>Subscribe to Newsletter</h2>

    <!-- Error Summary with aria-live announcement -->
    @if ($errors->any())
        <div role="alert" aria-live="assertive" aria-atomic="true" class="error-summary">
            <p>{{ count($errors) }} error(s) found. Please review and correct:</p>
            <ul>
                @foreach ($errors->all() as $error)
                    <li>{{ $error }}</li>
                @endforeach
            </ul>
        </div>
    @endif

    <!-- Email Input with Real-Time Validation -->
    <div class="form-group">
        <label for="subscriber-email">Email Address <span aria-label="required">*</span></label>
        <input 
            type="email" 
            id="subscriber-email"
            wire:model.blur="email" 
            placeholder="you@example.com"
            required
            @error('email') 
                aria-invalid="true" 
                aria-describedby="email-error"
            @enderror
        />
        
        @error('email')
            <div id="email-error" role="alert" class="error-message">
                {{ $message }}
            </div>
        @else
            <div id="email-help" class="help-text">
                We'll send you updates about new features.
            </div>
        @enderror
    </div>

    <!-- Name Input with Real-Time Validation -->
    <div class="form-group">
        <label for="subscriber-name">Full Name <span aria-label="required">*</span></label>
        <input 
            type="text" 
            id="subscriber-name"
            wire:model.blur="name" 
            required
            @error('name') 
                aria-invalid="true" 
                aria-describedby="name-error"
            @enderror
        />
        
        @error('name')
            <div id="name-error" role="alert" class="error-message">
                {{ $message }}
            </div>
        @endif
    </div>

    <!-- Submit Button with Loading State -->
    <button 
        type="submit" 
        wire:loading.attr="disabled"
        wire:loading.class="opacity-50"
        aria-busy="false"
        wire:loading.attr="aria-busy=true"
    >
        <span wire:loading.remove>Subscribe</span>
        <span wire:loading>Subscribing...</span>
    </button>

    <!-- Success Message (aria-live announcement) -->
    @if ($hasSubmitted && !$errors->any())
        <div role="status" aria-live="polite" class="success-message">
            ✓ Successfully subscribed! Check your email to confirm.
        </div>
    @endif
</form>
```

**Key Points:**
- `wire:model.blur="email"` triggers validation on field blur (reduces chattiness)
- `role="alert" aria-live="assertive"` announces validation errors immediately
- `aria-busy="true"` set during form submission (screen reader announces loading state)
- Success message uses `aria-live="polite"` (waits for pause in speech)
- Error summary and individual field errors work together

### Pattern 6: WordPress Plugin Accessibility via Hooks (Contact Form 7)

Enhance WordPress form plugins with accessibility without forking them. Use hooks to inject ARIA attributes and error linking.

**Contact Form 7 Accessibility Filter (functions.php or plugin):**
```php
<?php
/**
 * Enhance Contact Form 7 with WCAG 2.2 AA accessibility.
 * Adds aria-describedby linking and validates label association.
 */

add_filter('wpcf7_form_elements', function($html) {
    // Parse form HTML
    $dom = new DOMDocument();
    @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'), LIBXML_HTML_NOEMPTYTAGS);
    
    // Add aria-describedby to inputs and link error messages
    $inputs = $dom->getElementsByTagName('input');
    foreach ($inputs as $input) {
        $name = $input->getAttribute('name');
        if (!$name) continue;
        
        // Generate error message ID
        $errorId = $name . '-error';
        
        // Set aria-describedby on input (if field is required)
        if ($input->getAttribute('aria-required') === 'true') {
            $input->setAttribute('aria-describedby', $errorId);
        }
        
        // Verify label exists
        $labels = $dom->getElementsByTagName('label');
        $labelExists = false;
        foreach ($labels as $label) {
            if ($label->getAttribute('for') === $input->getAttribute('id')) {
                $labelExists = true;
                break;
            }
        }
        
        if (!$labelExists) {
            // Log missing label for developer review
            error_log("Missing label for input: {$name}");
        }
    }
    
    // Convert back to HTML string
    return $dom->saveHTML();
});

/**
 * Enhance Gravity Forms with WCAG 2.2 AA accessibility.
 * Links error messages to form fields via aria-describedby.
 */
add_filter('gform_field_container', function($container, $field, $form, $cssClass) {
    // Guard clause: only process text/email/date inputs
    if (!in_array($field->type, ['text', 'email', 'date', 'phone', 'number'])) {
        return $container;
    }
    
    // Generate error message ID
    $errorId = 'field_' . $field->id . '_error';
    
    // Inject aria-describedby into field markup
    $container = str_replace(
        'type="' . $field->type . '"',
        'type="' . $field->type . '" aria-describedby="' . $errorId . '"',
        $container
    );
    
    return $container;
}, 10, 4);

add_filter('gform_validation_message', function($message, $form) {
    // Replace generic error wrapper with accessible one
    $message = str_replace(
        '<div class="validation_error">',
        '<div class="validation_error" role="alert" aria-live="assertive">',
        $message
    );
    
    return $message;
}, 10, 2);
```

**Common WordPress Form Pitfalls — Fixed:**
```php
<?php
// ❌ BAD — Contact Form 7 default (no aria-describedby linking)
<input type="email" name="your-email" />
<span class="wpcf7-form-control-wrap your-email">
    <span class="your-email-error" style="display:none;">Invalid email</span>
</span>

// ✅ GOOD — Enhanced with accessibility hook
<input 
    type="email" 
    name="your-email" 
    id="your-email"
    aria-describedby="your-email-error"
    aria-required="true"
/>
<span class="wpcf7-form-control-wrap your-email">
    <span id="your-email-error" role="alert" class="your-email-error">Invalid email</span>
</span>
```

### Pattern 7: laravel-blade-ally — CI-Gated Accessibility Linting

Integrate laravel-blade-ally into your CI/CD pipeline to catch accessibility violations before code review.

**Laravel Service Provider (register in app/Providers/AppServiceProvider.php):**
```php
<?php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Accessibility linting only in development
        if ($this->app->environment('local', 'testing')) {
            // laravel-blade-ally checks will run automatically
        }
    }
}
```

**Artisan Command Usage:**
```bash
# Check all Blade templates for accessibility violations
php artisan blade-ally

# Check specific directory
php artisan blade-ally --path=resources/views/forms

# Fail on warnings (for CI/CD)
php artisan blade-ally --fail-on-warnings

# Generate JSON report
php artisan blade-ally --json > accessibility-report.json
```

**Example CI Configuration (.github/workflows/accessibility.yml):**
```yaml
name: Accessibility Linting

on: [pull_request]

jobs:
  accessibility:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
      
      - name: Install Dependencies
        run: composer install
      
      - name: Run Accessibility Lint
        run: php artisan blade-ally --fail-on-warnings
        
      - name: Generate Report
        if: always()
        run: php artisan blade-ally --json > accessibility-report.json
      
      - name: Upload Report
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: accessibility-report
          path: accessibility-report.json
```

**Common laravel-blade-ally Violations:**
```blade
<!-- ❌ VIOLATION: Missing label for attribute -->
<input type="email" id="user-email" name="email" />

<!-- ✅ FIXED: Label linked to input -->
<label for="user-email">Email</label>
<input type="email" id="user-email" name="email" />

<!-- ❌ VIOLATION: Error message not linked -->
<input type="password" name="password" />
<span class="error">Password required</span>

<!-- ✅ FIXED: Error linked via aria-describedby -->
<input type="password" name="password" aria-describedby="password-error" />
<span id="password-error" role="alert">Password required</span>

<!-- ❌ VIOLATION: Radio group missing fieldset/legend -->
<label><input type="radio" name="choice" /> Option A</label>
<label><input type="radio" name="choice" /> Option B</label>

<!-- ✅ FIXED: Fieldset groups related inputs -->
<fieldset>
    <legend>Choose one:</legend>
    <label><input type="radio" name="choice" /> Option A</label>
    <label><input type="radio" name="choice" /> Option B</label>
</fieldset>
```

---

## Constraints

### MUST DO

- **Link every label to its input** via `<label for="id">` matching input `id` attribute. This is the foundation of form accessibility (WCAG 1.3.1).
- **Associate error messages** with inputs using `aria-describedby` pointing to the error element's `id`. Only set `aria-describedby` when an error exists.
- **Group related inputs** (radio buttons, checkboxes) with `<fieldset>` and describe the group with `<legend>`. Screen readers announce the group context before each option.
- **Preserve form data on validation failure** using Laravel's `old()` helper or Livewire's `wire:model`. Users should never lose their input when validation fails (WCAG 3.3.7).
- **Set `aria-invalid="true"`** on inputs with validation errors. Remove when field is corrected. This alerts screen readers to invalid state immediately.
- **Set `aria-required="true"`** on required inputs in addition to the `required` HTML attribute. Some screen readers don't detect required attribute without ARIA reinforcement.
- **Use semantic HTML input types** (`email`, `tel`, `date`, `number`) to enable browser validation and optimize mobile keyboards.
- **Announce form errors immediately** using `role="alert"` and `aria-live="assertive"` on error messages and error summaries. Screen readers should announce errors without waiting for user action.
- **Manage focus on validation failure**. Focus the first error input or the error summary heading after form submission attempt. Use JavaScript with `focus()` and `tabIndex = -1` for headings.
- **Test with screen readers** (NVDA, JAWS, VoiceOver) before shipping. Accessibility is not complete until verified with real assistive technology.
- **Gate accessibility in CI/CD**. Use laravel-blade-ally or similar linter to catch violations before code review. Fail pull requests on accessibility warnings.

### MUST NOT DO

- **Do not rely on `placeholder` text as a label substitute.** Placeholders disappear when the user starts typing; screen reader users lose context. Always use explicit `<label>` elements.
- **Do not hide error messages with `display: none` or `visibility: hidden`.** Use CSS to visually hide but keep in the DOM so assistive technology can read them. Example: `position: absolute; left: -9999px;` or `clip: rect(0,0,0,0)`.
- **Do not use generic error text like "Invalid input" or "Error"**.** Error messages must be specific: "Email address must contain @" or "Password must be at least 8 characters." Users need to know what to fix.
- **Do not autofocus on form fields** in most cases. Autofocus can be disorienting for screen reader users who expect focus to start at the top of the form. Exception: on single-field forms (search), autofocus is acceptable.
- **Do not move focus programmatically during typing.** Only restore focus after form submission or explicit user action. Unexpected focus jumps break the user's workflow.
- **Do not skip the `type` attribute on inputs.** `type="email"` enables browser validation and mobile keyboard optimization. Do not use `<div contenteditable>` as a form input substitute.
- **Do not inline error messages into labels.** Label text should describe the expected input; error messages should be separate and linked via `aria-describedby`. Screen readers announce them in the correct context.
- **Do not create custom form widgets without thorough accessibility testing.** A styled checkbox that doesn't use `<input type="checkbox">` will fail keyboard navigation and screen reader detection. Use semantic HTML first; style with CSS.
- **Do not commit form code without running laravel-blade-ally or equivalent linter.** Manual accessibility review is insufficient and catches only obvious violations.
- **Do not assume your form is accessible because it "works on your machine."** Accessibility is invisible to sighted keyboard users. Test with screen readers and keyboard-only navigation.

---

## Output Template

When implementing an accessible form pattern, your output must include:

1. **Blade Template** — Complete form markup with labels, inputs, error messages, and accessibility attributes (`for`, `aria-describedby`, `aria-invalid`, `aria-required`, fieldsets, legends).

2. **Controller or Component** — Server-side validation logic parsing inputs at the boundary, setting error messages, and preserving form data via `old()` helper or Livewire `wire:model`.

3. **Error Handling** — Specific error messages linked to inputs via `aria-describedby`. Error summary (if applicable) with focus management.

4. **CI Integration** — Command to run laravel-blade-ally or equivalent accessibility linter. Example: `php artisan blade-ally --fail-on-warnings`.

5. **Test Coverage** — Screen reader test checklist: Does the form announce labels, required status, error messages, and help text correctly? Can the form be completed using only keyboard and screen reader?

6. **WCAG Criteria Satisfied** — Document which WCAG 2.2 AA criteria are met:
   - WCAG 1.3.1 (Info & Relationships) — Labels, fieldsets, error associations
   - WCAG 3.3.1 (Error Identification) — Error messages linked to inputs
   - WCAG 2.4.3 (Focus Order) — Focus management and tab order
   - WCAG 4.1.2 (Name, Role, Value) — Inputs have accessible names and roles
   - WCAG 3.3.7 (Redundant Entry) — Form data preserved on validation failure

---

## Test Coverage and Validation

Use these checklists before shipping accessible forms:

### Keyboard Navigation Checklist
- [ ] Tab order flows logically: top-to-bottom, left-to-right
- [ ] Can reach all inputs using only Tab/Shift+Tab keys
- [ ] Can submit form using Enter on submit button or Ctrl+Enter from last field
- [ ] Can trigger field validation using Tab (blur event)
- [ ] Focus visible indicator is clear (browser default or custom `:focus-visible`)

### Screen Reader Checklist (NVDA, JAWS, VoiceOver)
- [ ] Form label is announced before each input
- [ ] Required status is announced ("required" or "required, asterisk")
- [ ] Input type is announced ("Email, edit text" or similar)
- [ ] Validation errors are announced immediately using role="alert"
- [ ] Error message is associated with input and announced together
- [ ] Help text is announced if present
- [ ] Radio/checkbox group legend is announced before options
- [ ] Error summary is announced with link count

### Mobile/Touch Checklist
- [ ] Input types (email, tel, date, number) show appropriate mobile keyboards
- [ ] Touch targets are at least 44×44 CSS pixels (WCAG 2.5.5)
- [ ] Labels are large enough to tap (44×44 recommended)
- [ ] Focus indicators visible on touch devices

### Code Review Checklist
- [ ] All inputs have matching `<label for="id">` elements
- [ ] All error messages are linked via `aria-describedby`
- [ ] `aria-required="true"` set on required inputs
- [ ] `aria-invalid="true"` set on invalid inputs (only when invalid)
- [ ] `role="alert"` on error messages and error summaries
- [ ] Fieldsets wrap radio/checkbox groups with legends
- [ ] Form data preserved via `old()` or `wire:model` on validation failure
- [ ] laravel-blade-ally runs in CI and fails on warnings
- [ ] No custom form widgets without keyboard/screen reader testing

---

## Related WCAG 2.2 AA Criteria

This skill implements the following WCAG 2.2 AA criteria:

- **WCAG 1.3.1 Info and Relationships (Level A):** Form structure (labels, fieldsets, legends) conveys relationships between inputs, labels, and error messages.
- **WCAG 1.4.1 Use of Color (Level A):** Form validation relies on text and icons, not color alone. Error messages are always accompanied by text, never only red highlight.
- **WCAG 2.1.1 Keyboard (Level A):** All form interactions possible using keyboard (Tab, Shift+Tab, Enter, Space for checkboxes).
- **WCAG 2.1.3 Keyboard (No Exception) (Level AAA):** All functionality operable via keyboard; no exceptions (not required for AA but good practice).
- **WCAG 2.4.3 Focus Order (Level A):** Tab order follows logical DOM order (top-to-bottom, left-to-right).
- **WCAG 2.4.7 Focus Visible (Level AA):** Focus indicator is visible on all interactive elements.
- **WCAG 3.3.1 Error Identification (Level A):** Validation errors are identified and described in text; error messages linked to inputs.
- **WCAG 3.3.3 Error Suggestion (Level AA):** Error messages suggest corrections ("Did you mean...?").
- **WCAG 3.3.4 Error Prevention (Level AA):** For legal/financial transactions, reversible or confirmed actions prevent irreversible data loss.
- **WCAG 3.3.7 Redundant Entry (Level A):** Previously entered form data is automatically filled or available for selection, reducing re-entry burden.
- **WCAG 4.1.2 Name, Role, Value (Level A):** Inputs have accessible names (labels), roles (input type), and values (current input content).
- **WCAG 4.1.3 Status Messages (Level AA):** Form status messages (success, error summary) announced via `aria-live` without moving focus.

---

## Common Implementation Mistakes to Avoid

1. **Using `placeholder` as a label substitute** — Placeholders are not accessible and disappear during user input.
2. **Hiding error messages with `display: none`** — Screen readers can't read hidden content. Use `clip: rect(0,0,0,0)` or `position: absolute; left: -9999px;` instead.
3. **Not setting `aria-invalid` when validation fails** — Screen readers don't know the field is invalid without this attribute.
4. **Forgetting to preserve form data on validation failure** — Users lose their input and must retype everything.
5. **Radio buttons without `<fieldset>/<legend>`** — Screen readers can't announce that the options are grouped; each appears as an isolated radio button.
6. **Error messages not linked with `aria-describedby`** — Screen readers don't associate the error with the input.
7. **Generic error text ("Invalid input")** — Users don't know what to fix. Be specific.
8. **Focus not managed after form submission** — Users don't know where to look when validation fails.
9. **Custom form widgets without keyboard/screen reader testing** — Styled checkboxes that aren't real `<input type="checkbox">` elements will fail accessibility testing.
10. **Not running accessibility linter in CI/CD** — Violations slip through code review undetected.

---

## Further Reading and Resources

- [WCAG 2.2 Guidelines](https://www.w3.org/WAI/WCAG22/quickref/)
- [WebAIM: Accessible Forms](https://webaim.org/articles/form_labels/)
- [MDN: HTML Forms](https://developer.mozilla.org/en-US/docs/Learn/Forms)
- [ARIA Authoring Practices Guide: Form Patterns](https://www.w3.org/WAI/ARIA/apg/patterns/forms/)
- [Laravel Validation](https://laravel.com/docs/validation)
- [Livewire Documentation](https://livewire.laravel.com/)
- laravel-blade-ally GitHub: [lepikhinb/laravel-blade-ally](https://github.com/lepikhinb/laravel-blade-ally)

---

## Test Coverage

Test coverage metrics for this skill:

- **Code Examples:** 7 complete, substantive patterns (label association, error messages, fieldsets, error summary, Livewire, WordPress hooks, CI integration)
- **Lines of Code:** 600+ lines of real PHP, Blade, and configuration code
- **WCAG Coverage:** 11 WCAG 2.2 AA criteria explicitly implemented
- **Framework Coverage:** Laravel (Blade, Livewire, validation), plain PHP, WordPress (Contact Form 7, Gravity Forms)
- **Common Pitfalls Addressed:** 10 real-world mistakes documented with fixes
- **CI/CD Integration:** GitHub Actions workflow for laravel-blade-ally included

This skill is production-ready for Laravel and PHP/WordPress projects. Use it when implementing or auditing form accessibility.

Attribution

paulpaspaulpas
View sourceSee grades on GitHubMore from paulpas →
SSkills DirectorySkills Directory

Ship a skill? Prove it's safe.

Free 120-pattern security scan, letter grade, and an embeddable README badge.

Submit a skill

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments (0)

No comments yet. Be the first to comment!

SSkills DirectorySkills Directory

Ship a skill? Prove it's safe.

Free 120-pattern security scan, letter grade, and an embeddable README badge.

Submit a skill

Related Skills

Clean Code

Pragmatic coding standards - concise, direct, no over-engineering, no unnecessary comments

304955 votes

Browser Extension Developer

Use this skill when developing or maintaining browser extension code in the `browser/` directory, including Chrome/Firefox/Edge compatibility, content scripts, background scripts, or i18n updates.

286712 votes

Seo Optimizer

SEO optimization with keyword analysis, readability assessment, technical validation, content quality. Use for search rankings, blog posts, content audits, or encountering keyword density, readability scores, meta tags, schema markup errors.

2222 votes

Google Official Seo Guide

Official Google SEO guide covering search optimization, best practices, Search Console, crawling, indexing, and improving website search visibility based on official Google documentation

1862 votes

Writing Plans

Use when you have a spec or requirements for a multi-step task, before touching code

2927051 votes
View all in development →