Build an Accessible Civic Portal

Build a MySQL-backed Laravel announcements portal with accessible publishing.

Introduction

30 Second Summary

Public announcements need to reach people who browse with a keyboard or enlarge text to make it readable. Unclear navigation or hidden form errors can block someone from essential information.

In this project, you will build Civic Bulletin, an accessibility-first local announcements portal with Laravel and MySQL. The finished workflow lets a local administrator publish a notice that immediately appears on the public page.

What You'll Build

Picture your new notice appearing on the public page moments after you publish it through the protected administrator form.

By the end of this project, you'll have:

  • A persistent public announcement feed that displays notices stored in MySQL even after the application restarts.
  • A protected publishing workflow that lets a signed-in local administrator add notices through a validated form.
  • An accessibility-first interface with keyboard navigation, a skip link, visible focus indicators, labels, instructions, and text error feedback.
  • Secret Mission: Produce repeatable keyboard and 200% zoom test evidence that records successful checks alongside unresolved risks.

Are there any prerequisites?

You need a Windows device with permission to install software. The guide walks you through every required tool, so no development setup or prior Laravel experience is assumed.

Before We Start

This checkpoint defines the boundary of your local announcement publishing workflow before any hands-on work begins. Formal DICT, privacy, procurement, and security approval remains outside this project.

Set Up Laravel and MySQL

A local announcement portal needs a predictable foundation before it can store a notice. Laravel will run the publishing workflow.

MySQL will preserve each announcement between restarts. This step creates the Windows environment and dedicated schema Laravel needs before it can run migrations.

In this step, get ready to:
  • Install the Windows development tools and confirm Microsoft Edge is available.
  • Create a dedicated MySQL schema and local application account.
  • Run a Laravel starter application connected to MySQL.
Install the Windows tools

Laravel Herd supplies PHP, Composer, Laravel, Node.js, and npm for your Windows environment. Visual Studio Code gives you a file editor that can open the project directly from PowerShell.

Microsoft Edge provides the browser where you will test the local portal. Confirm that Windows can launch it before you continue.

  • Press the Windows key to open Windows search.
  • Type Microsoft Edge and press Enter.

You should see Microsoft Edge open to a browser window.

Cannot find Microsoft Edge?

Help me confirm or install Microsoft Edge on Windows.

MySQL Community Server 8.4.11 LTS stores the portal data locally. MySQL Configurator sets the network port and Windows service behavior.

The installation path depends on the MySQL version currently available on your machine.

✔️ I see version 8.4.11 LTS

MySQL Community Server is already at the project version.

  • Continue to the shared MySQL Configurator instructions below.

ⓧ I see an older version

The project uses MySQL Community Server 8.4.11 LTS.

ⓧ MySQL is not installed yet

A fresh MSI installation provides MySQL Server and MySQL Configurator.

The root password controls the local database superuser, so give it a strong value that you can retrieve safely.

  • Keep TCP/IP enabled in MySQL Configurator.
  • Keep the TCP/IP port set to 3306.
  • Keep the MySQL Windows service enabled.
  • Keep automatic startup enabled for the MySQL Windows service.
  • Create a strong root password in the Root Account Password field.
  • Store the root password in your password manager.
  • Complete the MySQL Configurator workflow.

That is the installation groundwork complete. Your database server now starts automatically with Windows and listens locally on port 3306.

Create the MySQL schema and account

Windows PowerShell lets you confirm that each command-line tool is ready. A fresh terminal session also picks up the commands added during installation.

  • Close any PowerShell windows left open from before the installations.
  • Press the Windows key to open Windows search.
  • Type PowerShell and press Enter.
  • Verify the Laravel Herd tools by running these commands:
herd --version
php --version
composer --version
node --version
laravel --version

What do these checks prove?

  • The Herd check confirms that the Windows environment command is available.
  • The PHP check confirms that the runtime is available for Laravel.
  • The Composer check confirms that PHP packages can be installed.
  • The Node.js check confirms that frontend dependencies can be built.
  • The Laravel check confirms that the application installer is available.

Each command should print a version. PHP should report 8.3 or higher, while the Laravel installer should report v5.32.0.

Seeing a command error?

  • Restart Windows if the new commands are still missing after reopening PowerShell.
  • Confirm that the Laravel Herd installation completed with administrator permission.
  • Reinstall Visual Studio Code with the User setup if the editor command is unavailable later.

Help me troubleshoot missing Herd, PHP, Composer, Node.js, or Laravel commands in PowerShell.

The first MySQL check confirms that the Windows service is answering connections. Before you run it, do you think PowerShell will reach the server or report that the service is unavailable?

  • Check the installed MySQL version and test the server by running these commands:
cd "C:\Program Files\MySQL\MySQL Server 8.4"
bin\mysql --version
bin\mysqlshow -u root -p

What does this server test do?

The first command moves PowerShell to the default MySQL 8.4 installation folder. The version check confirms which MySQL client is installed.

The final command asks the running server to list its databases. The -p option prompts for the root password without placing it in the command.

Enter the root password when prompted. You should see MySQL version 8.4.11 and a list of databases from the local server.

Cannot reach the MySQL server?

  • Confirm that the MySQL Configurator workflow completed.
  • Confirm that the MySQL Windows service is running.
  • Check that PowerShell is using the default MySQL 8.4 installation folder.

Help me troubleshoot the MySQL server test on Windows.

The portal uses a dedicated database account so the application never connects as the MySQL root superuser. Its privileges apply only to the civic_bulletin schema.

  • Open the MySQL command prompt as root by running this command:
bin\mysql -u root -p

What does this command do?

The MySQL client opens an interactive database session as the local root account. It prompts for the root password before accepting SQL statements.

MySQL prints the generated application password once, so keep your password manager ready before you continue.

  • Enter the root password when MySQL prompts for it.
  • Create the schema and application account by entering these statements:
CREATE DATABASE civic_bulletin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'civic_portal'@'localhost' IDENTIFIED BY RANDOM PASSWORD;
GRANT ALL ON civic_bulletin.* TO 'civic_portal'@'localhost';
EXIT

What do these statements create?

  • The database statement creates civic_bulletin with the utf8mb4 character set and utf8mb4_unicode_ci collation.
  • The user statement creates civic_portal@localhost with a password generated by MySQL.
  • The grant statement limits the application account to the civic_bulletin.* database scope.
  • The final statement returns you to PowerShell.
  • Copy the generated civic_portal password into your password manager.

Before you check the server again, which new schema do you expect to see in the database list?

  • Confirm that the schema exists by running this command:
bin\mysqlshow -u root -p

What should this confirm?

This repeats the documented server test after creating the schema. The updated database list should now include civic_bulletin.

You should see civic_bulletin in the database list after entering the root password. Your dedicated storage boundary is ready for Laravel.

Create and run the Laravel application

The Livewire starter kit supplies registration and authentication screens. Tailwind CSS supplies the styling foundation that later steps customize.

The Laravel installer opens an interactive wizard while it creates the application. Use the project choices below when their prompts appear.

  • Choose Livewire as the starter kit.
  • Keep the built-in authentication option.
  • Choose MySQL as the database.
  • Keep the defaults for any remaining installer prompts.
  • Create my-app inside your Herd folder by running these commands:
cd ~\Herd
laravel new my-app
cd my-app
code .

What does the scaffold create?

The installer creates the Laravel application at %USERPROFILE%\Herd\my-app. It adds the Livewire starter kit and built-in authentication.

The final command opens the new folder in Visual Studio Code. The first scaffold downloads project packages, so quiet periods during this command are normal.

You should see the my-app files in the Visual Studio Code file sidebar.

Did the project scaffold stop?

  • Confirm that each Laravel installer prompt has an answer.
  • Confirm that PowerShell is inside %USERPROFILE%\Herd.
  • Restart PowerShell if the Laravel or Visual Studio Code commands are unavailable.

Help me troubleshoot the Laravel Livewire starter-kit scaffold on Windows.

Laravel reads its active database connection from .env. This file holds the generated application password locally and stays outside source control.

  • Select .env from the Visual Studio Code file sidebar.
  • Find the generated database configuration block.
  • Replace the database block with this configuration:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=civic_bulletin
DB_USERNAME=civic_portal
DB_PASSWORD="your-generated-password"

How does this configuration connect?

Laravel connects to the local MySQL server at 127.0.0.1 on port 3306. It selects the civic_bulletin schema.

The application authenticates as civic_portal. The password placeholder must be replaced with the generated password saved in your password manager.

  • Replace your-generated-password with the generated civic_portal password.
  • Press Ctrl+S to save .env.

✔️ Awesome, I've got everything!

Your database block is saved with the generated application password in DB_PASSWORD.

ⓧ I'd like to double check the full code

Compare the database block in your generated .env file with this reference. Keep the other starter settings generated by Laravel.

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=civic_bulletin
DB_USERNAME=civic_portal
DB_PASSWORD="your-generated-password"

Compare the password safely

Your saved file must contain the generated civic_portal password on the final line. Keep that value out of screenshots and source control.

Laravel may still hold an older database configuration in its cache. The next commands clear that cache before applying the starter migrations.

  • Verify Laravel and initialize the MySQL-backed application by running these commands in PowerShell:
php artisan --version
php artisan config:clear
php artisan migrate
php artisan db:show

What do these Artisan commands prove?

  • The version command confirms the framework installed in my-app.
  • The configuration command removes cached settings so Laravel reads the updated .env file.
  • The migration command creates the starter authentication tables in MySQL.
  • The database inspection command reports the active database driver and table summary.

The version check should identify Laravel 13.32.0. The database overview should identify MySQL and the civic_bulletin database.

Cannot migrate to MySQL?

  • Confirm that DB_PASSWORD contains the generated application password.
  • Confirm that the MySQL Windows service is running.
  • Confirm that civic_bulletin appears in the MySQL database list.
  • Run the configuration clear command again after saving .env.

Help me troubleshoot Laravel's connection to the civic_bulletin MySQL database.

The starter kit also needs its frontend packages installed and compiled. The build turns its Tailwind CSS and JavaScript sources into browser-ready assets.

  • Install the frontend dependencies and build the assets by running these commands:
npm install
npm run build

What does the frontend build do?

The install command downloads the packages declared by the starter kit. The build command compiles the CSS and JavaScript used by the browser.

You should see the package installation finish and a successful frontend build summary.

Did the frontend build fail?

  • Confirm that PowerShell is inside %USERPROFILE%\Herd\my-app.
  • Confirm that the Node.js version check succeeded earlier.
  • Run the package installation command again if the dependency download was interrupted.

Help me troubleshoot npm installation or the Laravel starter-kit frontend build.

The development command keeps the local application available while you work. Its PowerShell window remains occupied until you stop the process.

Before you start the server, what do you expect Microsoft Edge to show when Laravel and MySQL are both configured correctly?

  • Start the Laravel development environment by running this command:
composer run dev

What happens while this runs?

The development process serves the Laravel application at http://localhost:8000. Keep this PowerShell window running while you work through the next steps.

  • Switch back to Microsoft Edge.
  • Enter http://localhost:8000 in the address bar.
  • Press Enter.

You should see the Laravel starter application in Microsoft Edge. That is the foundation working from browser to Laravel to MySQL.

Cannot see the starter application?

  • Keep the PowerShell window running the development command open.
  • Confirm that the browser address is http://localhost:8000.
  • Check the PowerShell output for a database connection or frontend build failure.

Help me troubleshoot why the Laravel starter application does not load in Microsoft Edge.

Your Windows development environment is ready and connected to a dedicated MySQL database. Next up, you will turn the starter screen into Civic Bulletin's public information front door.

Build the Public Information Front Door

Your Laravel starter application is running against MySQL. Its default home page does not yet look like a clear source of civic notices.

This step replaces that starter screen with a public Blade shell styled through Tailwind CSS. A temporary publishing link will reveal the administrative workflow the portal still needs.

In this step, get ready to:
  • Create a shared Civic Bulletin layout for public pages.
  • Replace the starter page with a temporary announcement and planned service cards.
  • Build the frontend assets and test the unfinished publishing link.
Create the public layout

A shared layout keeps the portal header and footer consistent across pages. The Vite directive loads the generated CSS and JavaScript assets into that layout.

  • In the Visual Studio Code Explorer, select the resources/views/components folder.
  • Use the new-file control in the Explorer toolbar.
  • Enter public-layout.blade.php as the file name.
  • Fill resources/views/components/public-layout.blade.php by pasting this code:
@props(['title' => 'Announcements'])

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{ $title }} | Civic Bulletin</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="min-h-screen bg-slate-50 text-slate-950">
    <header class="bg-blue-950 text-white">
        <div class="mx-auto flex max-w-6xl flex-col gap-4 px-6 py-6 md:flex-row md:items-center md:justify-between">
            <p class="text-sm font-semibold uppercase tracking-wide text-blue-200">Public Information System</p>
            <a href="{{ route('home') }}" class="mt-1 inline-block text-2xl font-bold">Civic Bulletin</a>
            <nav aria-label="Primary" class="flex flex-wrap items-center gap-3 text-sm font-semibold">
                <a href="{{ route('home') }}" class="rounded-md px-3 py-2 hover:bg-blue-900">Announcements</a>
                <a href="/admin/announcements/create" class="rounded-md bg-amber-300 px-3 py-2 text-slate-950 hover:bg-amber-200">Publish announcement</a>
            </nav>
        </div>
    </header>

    <main id="main-content" class="mx-auto max-w-6xl px-6 py-10">
        {{ $slot }}
    </main>
    <footer class="border-t border-slate-300 bg-white">
        <div class="mx-auto max-w-6xl px-6 py-6 text-sm text-slate-700">Prototype only. Formal accessibility, privacy, procurement, and security review is required before deployment.</div>
    </footer>
</body>
</html>

What Does This Layout Do?

  • The title property gives each public page its own browser tab title.
  • The @vite directive loads the project stylesheet and JavaScript entry point.
  • The header gives visitors a consistent portal name and primary navigation.
  • The main element receives each page through the component slot.
  • The footer clearly identifies the portal as a local prototype requiring formal review before deployment.
  • Save resources/views/components/public-layout.blade.php.
  • Confirm that public-layout.blade.php appears under the components folder in the Explorer.

Your shared public shell now has a clear header and a prototype disclaimer. Every public page can reuse this structure.

Can't Find the Layout File?

  • Check that the file sits inside resources/views/components.
  • Confirm that its complete name is public-layout.blade.php.
  • Remove any extra file extension that Visual Studio Code added to the name.

Ask for help with the component file location.

✔️ Awesome, I've got everything!

Great. Double-check that the layout file is saved before continuing.

ⓧ I'd like to double check the full code

@props(['title' => 'Announcements'])

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{ $title }} | Civic Bulletin</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="min-h-screen bg-slate-50 text-slate-950">
    <header class="bg-blue-950 text-white">
        <div class="mx-auto flex max-w-6xl flex-col gap-4 px-6 py-6 md:flex-row md:items-center md:justify-between">
            <p class="text-sm font-semibold uppercase tracking-wide text-blue-200">Public Information System</p>
            <a href="{{ route('home') }}" class="mt-1 inline-block text-2xl font-bold">Civic Bulletin</a>
            <nav aria-label="Primary" class="flex flex-wrap items-center gap-3 text-sm font-semibold">
                <a href="{{ route('home') }}" class="rounded-md px-3 py-2 hover:bg-blue-900">Announcements</a>
                <a href="/admin/announcements/create" class="rounded-md bg-amber-300 px-3 py-2 text-slate-950 hover:bg-amber-200">Publish announcement</a>
            </nav>
        </div>
    </header>

    <main id="main-content" class="mx-auto max-w-6xl px-6 py-10">
        {{ $slot }}
    </main>
    <footer class="border-t border-slate-300 bg-white">
        <div class="mx-auto max-w-6xl px-6 py-6 text-sm text-slate-700">Prototype only. Formal accessibility, privacy, procurement, and security review is required before deployment.</div>
    </footer>
</body>
</html>
Replace the starter page

The home page needs enough real structure to test the public experience before database records exist. A temporary announcement card gives you that visible result while the planned service cards establish the portal's future scope.

  • In the Visual Studio Code Explorer, select resources/views/welcome.blade.php.
  • Replace the existing contents of resources/views/welcome.blade.php with this code:
<x-public-layout title="Public Information">
    <section aria-labelledby="announcements-heading">
        <p class="text-sm font-semibold uppercase tracking-wide text-blue-800">Official updates</p>
        <h1 id="announcements-heading" class="mt-2 text-3xl font-bold tracking-tight text-slate-950">Public Information</h1>
        <p class="mt-3 max-w-3xl text-lg leading-8 text-slate-700">Read current service notices and agency information published through the local administrative workflow.</p>
        <div class="mt-8 grid gap-5 md:grid-cols-2">
            <article class="rounded-xl border border-slate-300 bg-white p-6 shadow-sm">
                <p class="text-sm font-semibold text-blue-800">Demo announcement</p>
                <h2 class="mt-2 text-xl font-bold text-slate-950">Civic Bulletin is ready for public notices</h2>
                <p class="mt-3 leading-7 text-slate-700">This temporary card demonstrates how a published notice will appear on the public page.</p>
            </article>
        </div>
    </section>

    <section aria-labelledby="services-heading" class="mt-12">
        <h2 id="services-heading" class="text-2xl font-bold text-slate-950">Public information services</h2>
        <div class="mt-5 grid gap-4 md:grid-cols-3">
            @foreach (['Procurement notices', 'Public data', 'Citizen feedback'] as $service)
                <div class="rounded-lg border border-slate-300 bg-white p-5">
                    <h3 class="font-bold text-slate-950">{{ $service }}</h3>
                    <p class="mt-2 text-sm leading-6 text-slate-700">Planned for a follow-on project after the publishing foundation is verified.</p>
                </div>
            @endforeach
        </div>
    </section>
</x-public-layout>

What Does This Page Do?

  • The x-public-layout component wraps the page in the shared header and footer.
  • The first section presents a temporary announcement inside an article element.
  • The second section generates the three planned service cards from one short list.
  • The responsive grid keeps every card in one column on narrow screens and spreads them across larger screens.
  • Save resources/views/welcome.blade.php.
  • Switch back to the existing Microsoft Edge tab.
  • Refresh http://localhost:8000.

You should see a dark blue Civic Bulletin header above the Public Information heading. The page also shows one announcement card and three planned service cards.

There it is. Your Laravel starter screen has become a public information portal that you can demonstrate in the browser.

Still Seeing the Starter Page?

  • Confirm that you edited resources/views/welcome.blade.php inside the open my-app project.
  • Save both Blade files before refreshing Microsoft Edge.
  • Check that the PowerShell window running the development server remains active.

Help me find why the Civic Bulletin page is not loading.

✔️ Awesome, I've got everything!

Your public page is visible. Make sure both Blade files are saved.

ⓧ I'd like to double check the full code

<x-public-layout title="Public Information">
    <section aria-labelledby="announcements-heading">
        <p class="text-sm font-semibold uppercase tracking-wide text-blue-800">Official updates</p>
        <h1 id="announcements-heading" class="mt-2 text-3xl font-bold tracking-tight text-slate-950">Public Information</h1>
        <p class="mt-3 max-w-3xl text-lg leading-8 text-slate-700">Read current service notices and agency information published through the local administrative workflow.</p>
        <div class="mt-8 grid gap-5 md:grid-cols-2">
            <article class="rounded-xl border border-slate-300 bg-white p-6 shadow-sm">
                <p class="text-sm font-semibold text-blue-800">Demo announcement</p>
                <h2 class="mt-2 text-xl font-bold text-slate-950">Civic Bulletin is ready for public notices</h2>
                <p class="mt-3 leading-7 text-slate-700">This temporary card demonstrates how a published notice will appear on the public page.</p>
            </article>
        </div>
    </section>

    <section aria-labelledby="services-heading" class="mt-12">
        <h2 id="services-heading" class="text-2xl font-bold text-slate-950">Public information services</h2>
        <div class="mt-5 grid gap-4 md:grid-cols-3">
            @foreach (['Procurement notices', 'Public data', 'Citizen feedback'] as $service)
                <div class="rounded-lg border border-slate-300 bg-white p-5">
                    <h3 class="font-bold text-slate-950">{{ $service }}</h3>
                    <p class="mt-2 text-sm leading-6 text-slate-700">Planned for a follow-on project after the publishing foundation is verified.</p>
                </div>
            @endforeach
        </div>
    </section>
</x-public-layout>
Rebuild assets and test the publishing link

The page now depends on the project's generated frontend assets for its Tailwind styling. Rebuilding those assets confirms that the public interface is ready for the final navigation check.

  • Select Terminal from the Visual Studio Code top menu.
  • Select New Terminal to create a separate integrated terminal.
  • Confirm that the terminal prompt is inside the my-app folder.
  • Build the frontend assets by running this command:
npm run build

What Does This Command Do?

The build command asks Vite to compile the project's CSS and JavaScript assets. A successful build confirms that the Tailwind classes used by the new Blade templates can be included in the generated frontend files.

  • Wait for the build command to finish.

You should see the build complete without an error. The PowerShell prompt becomes available again when the assets are ready.

Did the Asset Build Fail?

  • Check that the integrated terminal opened inside the my-app folder.
  • Confirm that the Node dependencies created during setup are still present in the project.
  • Keep the separate PowerShell window running the development server active.

Help me troubleshoot the failed frontend build.

  • Return to the existing Microsoft Edge tab.
  • Refresh http://localhost:8000.

Before following the publishing link, predict whether the portal already has a page that can receive a new announcement.

  • Press Tab until Publish announcement receives focus.
  • Press Enter to follow the link.

You'll reach a 404 page. This is the intended shortfall.

The public shell now exists. Its publishing workflow still needs a route.

Didn't Reach the 404 Page?

  • Confirm that the link text reads Publish announcement.
  • Check that the link targets /admin/announcements/create.
  • Return to the public page before testing the keyboard path again.

Help me check the temporary publishing link.

Your Civic Bulletin now has a public front door and a visible gap in its publishing workflow. Next, you'll replace the demonstration card with announcements stored in MySQL.

Store Announcements in MySQL

Your Civic Bulletin page now gives visitors a clear public front door. However, editing the template is still the only way to change its sample announcement.

This step moves announcement data into MySQL. Laravel will use an Eloquent model to retrieve each stored notice for the public page.

In this step, get ready to:
  • Create the announcement model and database table.
  • Connect the public route to an announcement controller.
  • Replace the static card with records from MySQL.
Create the announcement data layer

A migration describes the table that MySQL creates. The model gives Laravel a PHP class for working with rows in that table.

  • Use the Visual Studio Code sidebar to create Announcement.php inside app/Models.
  • Fill app/Models/Announcement.php by pasting this code:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Announcement extends Model
{
    // Values are assigned explicitly in the controller.
}

What does this model do?

  • The Announcement class represents records in the announcements table.
  • Extending Model gives the class Eloquent's database features.
  • The comment records that future controller code assigns each accepted value explicitly.
  • Save app/Models/Announcement.php.
  • Confirm that Announcement.php appears inside app/Models in the Visual Studio Code sidebar.

Model file in the wrong folder?

Check that the file path is app/Models/Announcement.php. The folder names use the capitalization shown here.

Ask for help checking the model path.

✔️ Awesome, I've got everything!

Your announcement model is saved in the correct Laravel folder.

ⓧ I'd like to double check the full code

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Announcement extends Model
{
    // Values are assigned explicitly in the controller.
}

How to use this reference

Compare this reference with your complete app/Models/Announcement.php file. Match the namespace and class name exactly.

  • Use the Visual Studio Code sidebar to create 2026_09_18_000001_create_announcements_table.php inside database/migrations.
  • Fill database/migrations/2026_09_18_000001_create_announcements_table.php by pasting this code:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('announcements', function (Blueprint $table) {
            $table->id();
            $table->string('title', 160);
            $table->text('summary');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('announcements');
    }
};

What does this migration do?

  • The up() method creates the announcements table.
  • The title column accepts up to 160 characters.
  • The summary column stores the notice body as text.
  • The timestamp columns record when each announcement is created or updated.
  • Save database/migrations/2026_09_18_000001_create_announcements_table.php.
  • Start a second PowerShell window from Windows search.
  • Move into the existing my-app project by running these commands:
cd ~\Herd
cd my-app

What do these commands do?

The first command moves PowerShell into your Herd folder. The second command enters the existing my-app project.

Before you run the migration, predict which new table should appear in the schema.

  • Apply the outstanding database migration by running:
php artisan migrate

What does this command do?

Artisan finds migrations that MySQL has not received yet. It applies the new table definition to civic_bulletin.

You should see the announcement-table migration complete successfully. That output proves Laravel reached MySQL and applied the schema change.

Migration did not complete?

  • Confirm that the second PowerShell window is inside %USERPROFILE%\Herd\my-app.
  • Check that the MySQL Windows service is still running.
  • Confirm that the database values in .env still point to civic_bulletin.

Help me diagnose the failed migration.

✔️ Awesome, I've got everything!

MySQL now has the table structure needed to store announcements.

ⓧ I'd like to double check the full code

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('announcements', function (Blueprint $table) {
            $table->id();
            $table->string('title', 160);
            $table->text('summary');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('announcements');
    }
};

How to use this reference

Compare this reference with your complete migration file. Check the table name and each column definition carefully.

Connect the controller and public route

A controller prepares the records that a page needs. The public route sends requests for / to that controller.

  • Use the Visual Studio Code sidebar to create AnnouncementController.php inside app/Http/Controllers.
  • Fill app/Http/Controllers/AnnouncementController.php by pasting this code:
<?php

namespace App\Http\Controllers;

use App\Models\Announcement;
use Illuminate\View\View;

class AnnouncementController extends Controller
{
    public function index(): View
    {
        return view('welcome', [
            'announcements' => Announcement::query()
                ->orderByDesc('created_at')
                ->get(),
        ]);
    }
}

What does this controller do?

  • The index() method retrieves every announcement.
  • The query places the newest records first using created_at.
  • The controller passes the collection to welcome under the announcements key.
  • Save app/Http/Controllers/AnnouncementController.php.
  • Confirm that AnnouncementController.php appears inside app/Http/Controllers.

Controller file showing an error?

Check the Announcement and View imports at the top of the file. Confirm that the controller namespace matches its folder.

Help me compare the controller structure.

✔️ Awesome, I've got everything!

Your controller can now retrieve announcements for the public page.

ⓧ I'd like to double check the full code

<?php

namespace App\Http\Controllers;

use App\Models\Announcement;
use Illuminate\View\View;

class AnnouncementController extends Controller
{
    public function index(): View
    {
        return view('welcome', [
            'announcements' => Announcement::query()
                ->orderByDesc('created_at')
                ->get(),
        ]);
    }
}

How to use this reference

Compare this reference with your complete controller file. The class contains only the public index() method at this stage.

  • Replace the entire contents of routes/web.php with this code:
<?php

use App\Http\Controllers\AnnouncementController;
use Illuminate\Support\Facades\Route;

Route::get('/', [AnnouncementController::class, 'index'])->name('home');

Route::view('dashboard', 'dashboard')
    ->middleware(['auth'])
    ->name('dashboard');

require __DIR__.'/settings.php';

What does this route file do?

  • The root route sends public requests to AnnouncementController@index.
  • The route keeps the name home for links back to the public page.
  • The generated authenticated dashboard route remains available.
  • The generated settings routes remain loaded from settings.php.
  • Save routes/web.php.

Before you inspect the routes, predict which controller should now handle the named home route.

  • Inspect the application's routes by running:
php artisan route:list

What does this command show?

The route list displays each registered path with its method and destination. It lets you confirm that the public request reaches the new controller.

You should see the named home route pointing to AnnouncementController. The protected publishing routes are still absent.

Home route missing or failing?

  • Check that routes/web.php imports AnnouncementController.
  • Confirm that the root route calls index.
  • Confirm that the route name is home.

Help me troubleshoot the public route.

✔️ Awesome, I've got everything!

The public route now reaches the announcement controller.

ⓧ I'd like to double check the full code

<?php

use App\Http\Controllers\AnnouncementController;
use Illuminate\Support\Facades\Route;

Route::get('/', [AnnouncementController::class, 'index'])->name('home');

Route::view('dashboard', 'dashboard')
    ->middleware(['auth'])
    ->name('dashboard');

require __DIR__.'/settings.php';

How to use this reference

Compare this reference with your complete routes/web.php file. Publishing routes do not belong in this step.

Render stored announcements

A Blade loop can render any number of records from the controller. Escaped output keeps each announcement title and summary as text.

  • In resources/views/welcome.blade.php, find the section containing the Public Information heading and temporary static card.
  • Replace that entire section with this announcement section:
    <section aria-labelledby="announcements-heading">
        <div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
            <div>
                <p class="text-sm font-semibold uppercase tracking-wide text-blue-800">Official updates</p>
                <h1 id="announcements-heading" class="mt-2 text-3xl font-bold tracking-tight text-slate-950">Public announcements</h1>
                <p class="mt-3 max-w-3xl text-lg leading-8 text-slate-700">Read current service notices and agency information published through the local administrative workflow.</p>
            </div>
        </div>

        <div class="mt-8 grid gap-5 md:grid-cols-2">
            @forelse ($announcements as $announcement)
                <article class="rounded-xl border border-slate-300 bg-white p-6 shadow-sm">
                    <time datetime="{{ $announcement->created_at->format('Y-m-d') }}" class="text-sm font-semibold text-blue-800">Published {{ $announcement->created_at->format('F j, Y') }}</time>
                    <h2 class="mt-2 text-xl font-bold text-slate-950">{{ $announcement->title }}</h2>
                    <p class="mt-3 whitespace-pre-line leading-7 text-slate-700">{{ $announcement->summary }}</p>
                </article>
            @empty
                <div class="rounded-xl border border-dashed border-slate-400 bg-white p-8 md:col-span-2">
                    <h2 class="text-lg font-bold text-slate-950">No announcements have been published yet.</h2>
                    <p class="mt-2 text-slate-700">An authenticated administrator can publish the first notice.</p>
                </div>
            @endforelse
        </div>
    </section>

How does the announcement list work?

  • The @forelse block renders one article for each announcement.
  • The created_at value becomes a readable publication date.
  • Blade's {{ }} syntax escapes the stored title and summary before displaying them.
  • The @empty block gives visitors a clear message when the table has no records.
  • Save resources/views/welcome.blade.php.

Before you refresh, predict what the page should show when the new table contains no announcements.

  • Refresh http://localhost:8000 in Microsoft Edge.

You should see the Public announcements heading with the message No announcements have been published yet. The three planned public information service cards should remain below it.

Public page not showing the empty state?

  • Confirm that the controller passes a value named announcements to the view.
  • Check that the loop in welcome.blade.php also uses $announcements.
  • Confirm that the named home route points to AnnouncementController@index.

Help me find why the empty state is missing.

✔️ Awesome, I've got everything!

Your public page now reads its announcement list from MySQL.

ⓧ I'd like to double check the full code

<x-public-layout title="Announcements">
    <section aria-labelledby="announcements-heading">
        <div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
            <div>
                <p class="text-sm font-semibold uppercase tracking-wide text-blue-800">Official updates</p>
                <h1 id="announcements-heading" class="mt-2 text-3xl font-bold tracking-tight text-slate-950">Public announcements</h1>
                <p class="mt-3 max-w-3xl text-lg leading-8 text-slate-700">Read current service notices and agency information published through the local administrative workflow.</p>
            </div>
        </div>

        <div class="mt-8 grid gap-5 md:grid-cols-2">
            @forelse ($announcements as $announcement)
                <article class="rounded-xl border border-slate-300 bg-white p-6 shadow-sm">
                    <time datetime="{{ $announcement->created_at->format('Y-m-d') }}" class="text-sm font-semibold text-blue-800">Published {{ $announcement->created_at->format('F j, Y') }}</time>
                    <h2 class="mt-2 text-xl font-bold text-slate-950">{{ $announcement->title }}</h2>
                    <p class="mt-3 whitespace-pre-line leading-7 text-slate-700">{{ $announcement->summary }}</p>
                </article>
            @empty
                <div class="rounded-xl border border-dashed border-slate-400 bg-white p-8 md:col-span-2">
                    <h2 class="text-lg font-bold text-slate-950">No announcements have been published yet.</h2>
                    <p class="mt-2 text-slate-700">An authenticated administrator can publish the first notice.</p>
                </div>
            @endforelse
        </div>
    </section>

    <section aria-labelledby="services-heading" class="mt-12">
        <h2 id="services-heading" class="text-2xl font-bold text-slate-950">Public information services</h2>
        <div class="mt-5 grid gap-4 md:grid-cols-3">
            @foreach (['Procurement notices', 'Public data', 'Citizen feedback'] as $service)
                <div class="rounded-lg border border-slate-300 bg-white p-5">
                    <h3 class="font-bold text-slate-950">{{ $service }}</h3>
                    <p class="mt-2 text-sm leading-6 text-slate-700">Planned for a follow-on project after the publishing foundation is verified.</p>
                </div>
            @endforeach
        </div>
    </section>
</x-public-layout>

How to use this reference

Compare this reference with your complete resources/views/welcome.blade.php file. It contains the database-backed announcement section plus the existing public information services section.

Before the final database check, predict whether the new table will appear beside the starter-kit tables.

  • Inspect the configured database by running:
php artisan db:show

What does this database check prove?

The database overview identifies the configured database system. Its table summary confirms which migrations exist inside civic_bulletin.

You should see MySQL as the database connection. You should also see the announcements table alongside the starter-kit tables.

Your public page now reads persistent announcement records from MySQL. Next, you will protect the missing publishing workflow and connect authenticated input to this table.

Protect the Publishing Flow

Your public page now pulls its announcement list from MySQL. The empty state proves that the reading side of Civic Bulletin works.

Publishing still has no controlled entrance. In this step, Laravel authentication will guard the publishing form.

A controller will validate each notice before saving it to MySQL. This creates the complete publishing loop.

In this step, get ready to:
  • Protect the publishing routes with authentication middleware.
  • Create a publishing form with CSRF protection.
  • Connect valid form submissions to MySQL.
Add authenticated publishing routes

Authentication middleware checks for a signed-in user before Laravel runs a protected route. Guest requests are redirected to the login page.

  • In the Visual Studio Code Explorer, select routes/web.php.
  • Find the authenticated dashboard route.
  • Find the line that loads settings.php.
  • Insert the protected publishing route group between them by copying this code:
Route::middleware(['auth'])->group(function () {
    Route::get('/admin/announcements/create', [AnnouncementController::class, 'create'])
        ->name('announcements.create');
    Route::post('/admin/announcements', [AnnouncementController::class, 'store'])
        ->name('announcements.store');
});

What does this route group do?

  • The auth middleware limits both routes to signed-in users.
  • The GET route will display the publishing form through create().
  • The POST route will submit the form to store().
  • The route names let Blade generate URLs without repeating path strings.
  • Save routes/web.php.
  • Switch back to the second PowerShell window.
  • List the application routes by running:
php artisan route:list

What should you see?

The route table should include announcements.create for the GET route. It should also include announcements.store for the POST route.

Before you test the protected page, do you expect a guest request to open the form or move to the login page?

  • Return to Microsoft Edge.
  • Enter http://localhost:8000/admin/announcements/create in the address bar.
  • Press Enter.

You should reach http://localhost:8000/login. The redirect proves that the publishing route rejects unauthenticated access.

Publishing route missing?

  • Confirm that the route group appears before the line that loads settings.php.
  • Check that both route definitions sit inside the same auth middleware group.
  • Confirm that you saved routes/web.php before listing the routes.

Help me diagnose my protected Laravel publishing routes.

✔️ Awesome, I've got everything!

Great. Save routes/web.php before continuing.

ⓧ I'd like to double check the full code

<?php

use App\Http\Controllers\AnnouncementController;
use Illuminate\Support\Facades\Route;

Route::get('/', [AnnouncementController::class, 'index'])->name('home');

Route::view('dashboard', 'dashboard')
    ->middleware(['auth'])
    ->name('dashboard');

Route::middleware(['auth'])->group(function () {
    Route::get('/admin/announcements/create', [AnnouncementController::class, 'create'])
        ->name('announcements.create');
    Route::post('/admin/announcements', [AnnouncementController::class, 'store'])
        ->name('announcements.store');
});

require __DIR__.'/settings.php';
Connect the controller to MySQL

The Eloquent model already reads announcements from MySQL. The controller now needs methods for displaying the form and storing a validated notice.

Server-side validation limits the accepted fields before the database write. Explicit assignment keeps the write focused on the validated title and summary.

  • In the Visual Studio Code Explorer, select app/Http/Controllers/AnnouncementController.php.
  • Find the import statements near the top of the file.
  • Add the response and request imports by making the import group match this code:
use App\Models\Announcement;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

Why add these imports?

  • Request provides the submitted form values to store().
  • RedirectResponse describes the response that sends the user back to the public page.
  • View remains the response type for pages rendered through Blade.
  • Find the closing brace of index().
  • Insert the new controller methods directly below it by copying this code:
    public function create(): View
    {
        return view('announcements.create');
    }

    public function store(Request $request): RedirectResponse
    {
        $validated = $request->validate([
            'title' => ['required', 'string', 'max:160'],
            'summary' => ['required', 'string', 'max:1000'],
        ]);

        $announcement = new Announcement;
        $announcement->title = $validated['title'];
        $announcement->summary = $validated['summary'];
        $announcement->save();

        return to_route('home')->with('status', 'Announcement published.');
    }

How does publishing work?

  • create() returns the publishing view for an authenticated user.
  • validate() accepts a title of up to 160 characters.
  • validate() accepts a summary of up to 1000 characters.
  • save() inserts the new announcement into MySQL.
  • to_route('home') returns the user to the public page with a status message.
  • Save app/Http/Controllers/AnnouncementController.php.
  • Confirm that Laravel still loads the controller by running:
php artisan route:list

What does this check prove?

You should see the route table again without a PHP syntax failure. The two publishing routes should still point to AnnouncementController.

Seeing a controller error?

  • Confirm that both imports appear below the Announcement import.
  • Check that create() appears after the complete index() method.
  • Check that the class still has one final closing brace after store().

Help me find the syntax problem in my announcement controller.

✔️ Awesome, I've got everything!

Your controller can now display the form and save a validated announcement.

ⓧ I'd like to double check the full code

<?php

namespace App\Http\Controllers;

use App\Models\Announcement;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

class AnnouncementController extends Controller
{
    public function index(): View
    {
        return view('welcome', [
            'announcements' => Announcement::query()
                ->orderByDesc('created_at')
                ->get(),
        ]);
    }

    public function create(): View
    {
        return view('announcements.create');
    }

    public function store(Request $request): RedirectResponse
    {
        $validated = $request->validate([
            'title' => ['required', 'string', 'max:160'],
            'summary' => ['required', 'string', 'max:1000'],
        ]);

        $announcement = new Announcement;
        $announcement->title = $validated['title'];
        $announcement->summary = $validated['summary'];
        $announcement->save();

        return to_route('home')->with('status', 'Announcement published.');
    }
}
Create and test the publishing form

A Blade form gives the authenticated administrator labeled controls for the two validated fields. Its POST request carries a CSRF token so Laravel can verify that the submission came from the application.

  • In the Visual Studio Code Explorer, expand resources/views.
  • Create an announcements folder inside resources/views.
  • Create create.blade.php inside resources/views/announcements.
  • Build the publishing form by copying this code into create.blade.php:
<x-public-layout title="Publish announcement">
    <div class="mx-auto max-w-2xl">
        <p class="text-sm font-semibold uppercase tracking-wide text-blue-800">Authenticated publishing</p>
        <h1 class="mt-2 text-3xl font-bold tracking-tight text-slate-950">Publish an announcement</h1>
        <p id="form-instructions" class="mt-3 leading-7 text-slate-700">Both fields are required. Use plain language and do not include personal or sensitive information.</p>

        <form method="POST" action="{{ route('announcements.store') }}" aria-describedby="form-instructions" class="mt-8 grid gap-6">
            @csrf
            <div>
                <label for="title" class="block font-bold text-slate-950">Title <span aria-hidden="true">*</span></label>
                <p id="title-help" class="mt-1 text-sm text-slate-700">Maximum 160 characters.</p>
                <input id="title" name="title" type="text" maxlength="160" required value="{{ old('title') }}" aria-describedby="title-help" class="mt-2 w-full rounded-md border border-slate-500 bg-white px-3 py-3 text-slate-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900">
            </div>
            <div>
                <label for="summary" class="block font-bold text-slate-950">Summary <span aria-hidden="true">*</span></label>
                <p id="summary-help" class="mt-1 text-sm text-slate-700">Maximum 1,000 characters. Plain text only.</p>
                <textarea id="summary" name="summary" rows="7" maxlength="1000" required aria-describedby="summary-help" class="mt-2 w-full rounded-md border border-slate-500 bg-white px-3 py-3 text-slate-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900">{{ old('summary') }}</textarea>
            </div>
            <div class="flex flex-wrap gap-3">
                <button type="submit" class="rounded-md bg-blue-900 px-5 py-3 font-bold text-white hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900">Publish announcement</button>
                <a href="{{ route('home') }}" class="rounded-md border border-slate-500 bg-white px-5 py-3 font-bold text-slate-900 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900">Cancel</a>
            </div>
        </form>
    </div>
</x-public-layout>

What does this form do?

  • The form submits a POST request to announcements.store.
  • @csrf adds Laravel's request-verification token.
  • maxlength reflects the same title and summary limits enforced by the controller.
  • old() repopulates submitted values when Laravel redirects back to the form.
  • Cancel returns the administrator to the public page without submitting the form.
  • Save resources/views/announcements/create.blade.php.
  • Return to Microsoft Edge.
  • Enter http://localhost:8000/register in the address bar.
  • Press Enter.
  • Complete every required registration field with local test account details.
  • Submit the registration form.

Your local account is now authenticated. The protected publishing page is ready for its first real notice.

  • Enter http://localhost:8000/admin/announcements/create in the address bar.
  • Press Enter.
  • Enter Water service maintenance in the Title field.
  • Enter Scheduled maintenance will affect the central district from 9 AM to 11 AM. in the Summary field.
  • Click Publish announcement.

You should return to the public page. You should see Announcement published. above a new announcement card containing your title and summary.

Announcement not appearing?

  • Confirm that the form action uses the named announcements.store route.
  • Check that the form contains @csrf directly after the opening form tag.
  • Check that the controller calls save() before returning to the home route.

Help me troubleshoot why my valid Laravel announcement is not appearing.

✔️ Awesome, I've got everything!

Your publishing form now sends authenticated notices into MySQL.

ⓧ I'd like to double check the full code

<x-public-layout title="Publish announcement">
    <div class="mx-auto max-w-2xl">
        <p class="text-sm font-semibold uppercase tracking-wide text-blue-800">Authenticated publishing</p>
        <h1 class="mt-2 text-3xl font-bold tracking-tight text-slate-950">Publish an announcement</h1>
        <p id="form-instructions" class="mt-3 leading-7 text-slate-700">Both fields are required. Use plain language and do not include personal or sensitive information.</p>

        <form method="POST" action="{{ route('announcements.store') }}" aria-describedby="form-instructions" class="mt-8 grid gap-6">
            @csrf
            <div>
                <label for="title" class="block font-bold text-slate-950">Title <span aria-hidden="true">*</span></label>
                <p id="title-help" class="mt-1 text-sm text-slate-700">Maximum 160 characters.</p>
                <input id="title" name="title" type="text" maxlength="160" required value="{{ old('title') }}" aria-describedby="title-help" class="mt-2 w-full rounded-md border border-slate-500 bg-white px-3 py-3 text-slate-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900">
            </div>
            <div>
                <label for="summary" class="block font-bold text-slate-950">Summary <span aria-hidden="true">*</span></label>
                <p id="summary-help" class="mt-1 text-sm text-slate-700">Maximum 1,000 characters. Plain text only.</p>
                <textarea id="summary" name="summary" rows="7" maxlength="1000" required aria-describedby="summary-help" class="mt-2 w-full rounded-md border border-slate-500 bg-white px-3 py-3 text-slate-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900">{{ old('summary') }}</textarea>
            </div>
            <div class="flex flex-wrap gap-3">
                <button type="submit" class="rounded-md bg-blue-900 px-5 py-3 font-bold text-white hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900">Publish announcement</button>
                <a href="{{ route('home') }}" class="rounded-md border border-slate-500 bg-white px-5 py-3 font-bold text-slate-900 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900">Cancel</a>
            </div>
        </form>
    </div>
</x-public-layout>

Before you test an invalid submission, do you think the form will clearly explain which field failed?

  • Return to http://localhost:8000/admin/announcements/create.
  • Enter Road closure update in the Title field.
  • Enter one space in the Summary field.
  • Click Publish announcement.

Laravel returns you to the form because the summary is invalid. The valid title remains in place, but no visible message identifies the failed field.

Why is the rejection hard to understand?

The controller securely rejects the invalid request. The Blade page does not yet render Laravel's validation errors.

This is the intended shortfall for this test. The next step turns that hidden rejection into visible recovery guidance.

Before the final access check, do you expect the protected URL to remain available after you sign out?

  • Return to the Civic Bulletin public page.
  • Click Log out in the primary navigation.
  • Enter http://localhost:8000/admin/announcements/create in the address bar.
  • Press Enter.

You should return to http://localhost:8000/login. The form remains available only to an authenticated local user.

Protected page still opening?

  • Confirm that you submitted the logout control before reopening the publishing URL.
  • Check that the GET publishing route remains inside the auth middleware group.
  • Refresh the publishing URL after the logout request completes.

Help me check why my Laravel publishing page remains accessible after logout.

Your portal now accepts authenticated announcements and persists them in MySQL. Next, you will make validation failures visible to keyboard users through an error summary and field-specific guidance.

Make Validation and Navigation Accessible

Your Laravel publishing flow already rejects blank announcements before they reach MySQL. However, the returned form hides the reason from the person submitting it.

This step makes validation feedback visible in text. It also gives keyboard users a direct route past repeated navigation.

In this step, get ready to:
  • Add skip navigation with visible keyboard focus.
  • Connect validation feedback to each form field.
  • Test the complete publishing flow without a mouse.
Add skip navigation and focus styles

A skip link lets keyboard users bypass the repeated header. Visible focus styles show which control receives the next keyboard action.

  • In the Visual Studio Code Explorer sidebar, select resources/views/components/public-layout.blade.php.
  • Select from the first @props(['title' => 'Announcements']) line through the closing div immediately after the Civic Bulletin link.
  • Replace that selection with the following layout opening:
@props(['title' => 'Announcements'])

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{ $title }} | Civic Bulletin</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="min-h-screen bg-slate-50 text-slate-950">
    <a href="#main-content" class="absolute left-4 top-4 z-50 -translate-y-24 rounded-md bg-amber-300 px-4 py-2 font-semibold text-slate-950 focus:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white">Skip to main content</a>

    <header class="bg-blue-950 text-white">
        <div class="mx-auto flex max-w-6xl flex-col gap-4 px-6 py-6 md:flex-row md:items-center md:justify-between">
            <div>
                <p class="text-sm font-semibold uppercase tracking-wide text-blue-200">Public Information System</p>
                <a href="{{ route('home') }}" class="mt-1 inline-block text-2xl font-bold focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-300">Civic Bulletin</a>
            </div>

What Does This Layout Code Do?

  • The skip link stays above the viewport until it receives focus.
  • The #main-content value identifies its destination.
  • The focus-visible utilities display an outline during keyboard navigation.
  • The Vite entry points load the existing Tailwind CSS styles.
  • Save resources/views/components/public-layout.blade.php.
  • Return to http://localhost:8000 in Microsoft Edge.
  • Refresh the public page.
  • Press Tab once from the top of the page.

You should see the amber Skip to main content link move into view. That first keyboard route is now visible.

Skip Link Staying Hidden?

  • Confirm the link appears immediately after the opening body element.
  • Check that its classes include -translate-y-24 plus focus:translate-y-0.
  • Confirm the Blade file is saved before refreshing the page.

Ask for help with the hidden link:

The primary navigation also needs visible focus. Its controls change according to the current authentication state.

  • Select the existing nav element in resources/views/components/public-layout.blade.php.
  • Replace the selected navigation with the following code:
            <nav aria-label="Primary" class="flex flex-wrap items-center gap-3 text-sm font-semibold">
                <a href="{{ route('home') }}" class="rounded-md px-3 py-2 hover:bg-blue-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-300">Announcements</a>
                @auth
                    <a href="{{ route('announcements.create') }}" class="rounded-md bg-amber-300 px-3 py-2 text-slate-950 hover:bg-amber-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white">Publish announcement</a>
                    <form method="POST" action="{{ route('logout') }}">
                        @csrf
                        <button type="submit" class="rounded-md px-3 py-2 hover:bg-blue-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-300">Log out</button>
                    </form>
                @else
                    <a href="{{ route('login') }}" class="rounded-md bg-white px-3 py-2 text-blue-950 hover:bg-blue-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-300">Admin sign in</a>
                @endauth
            </nav>

How Does the Navigation Respond?

  • The @auth branch shows publishing controls to a signed-in user.
  • The @else branch shows the local sign-in link to a guest.
  • The logout form retains @csrf protection.
  • Every interactive control has an explicit keyboard focus outline.
  • Save resources/views/components/public-layout.blade.php.
  • Refresh the public page.
  • Press Tab repeatedly through the header controls.

You should see a visible outline move through each available link. The logout button receives the same treatment while you are signed in.

Focus Outline Missing?

  • Confirm each navigation control includes focus-visible:outline-2.
  • Use the keyboard for this check because pointer input can produce a different focus state.
  • Check that the logout button remains inside the protected form.

Ask for help with a missing outline:

The skip link needs a focusable destination. The main landmark provides that destination without adding another stop to the normal tab order.

  • Select from the opening main element through the end of resources/views/components/public-layout.blade.php.
  • Replace that selection with the following layout ending:
    <main id="main-content" tabindex="-1" class="mx-auto max-w-6xl px-6 py-10 focus:outline-none">
        {{ $slot }}
    </main>

    <footer class="border-t border-slate-300 bg-white">
        <div class="mx-auto max-w-6xl px-6 py-6 text-sm text-slate-700">Prototype only. Formal accessibility, privacy, procurement, and security review is required before deployment.</div>
    </footer>
</body>
</html>

Why Make the Main Landmark Focusable?

The id="main-content" value matches the skip link target. The tabindex="-1" value lets the link move focus to the landmark.

The main landmark stays outside the normal tab sequence. The skip link provides the direct route.

  • Save resources/views/components/public-layout.blade.php.
  • Refresh the public page.
  • Press Tab until Skip to main content receives focus.
  • Press Enter.

Focus should move directly to the public announcements content. The repeated header controls are bypassed.

Skip Link Not Moving Focus?

  • Confirm the skip link target is exactly #main-content.
  • Confirm the main landmark uses id="main-content".
  • Check that tabindex="-1" remains on the opening main element.

Ask for help with the focus target:

✔️ Awesome, I've got everything!

Your public layout now provides skip navigation with visible focus. Confirm that resources/views/components/public-layout.blade.php is saved.

ⓧ I'd like to double check the full code

@props(['title' => 'Announcements'])

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{ $title }} | Civic Bulletin</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="min-h-screen bg-slate-50 text-slate-950">
    <a href="#main-content" class="absolute left-4 top-4 z-50 -translate-y-24 rounded-md bg-amber-300 px-4 py-2 font-semibold text-slate-950 focus:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white">Skip to main content</a>

    <header class="bg-blue-950 text-white">
        <div class="mx-auto flex max-w-6xl flex-col gap-4 px-6 py-6 md:flex-row md:items-center md:justify-between">
            <div>
                <p class="text-sm font-semibold uppercase tracking-wide text-blue-200">Public Information System</p>
                <a href="{{ route('home') }}" class="mt-1 inline-block text-2xl font-bold focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-300">Civic Bulletin</a>
            </div>

            <nav aria-label="Primary" class="flex flex-wrap items-center gap-3 text-sm font-semibold">
                <a href="{{ route('home') }}" class="rounded-md px-3 py-2 hover:bg-blue-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-300">Announcements</a>
                @auth
                    <a href="{{ route('announcements.create') }}" class="rounded-md bg-amber-300 px-3 py-2 text-slate-950 hover:bg-amber-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white">Publish announcement</a>
                    <form method="POST" action="{{ route('logout') }}">
                        @csrf
                        <button type="submit" class="rounded-md px-3 py-2 hover:bg-blue-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-300">Log out</button>
                    </form>
                @else
                    <a href="{{ route('login') }}" class="rounded-md bg-white px-3 py-2 text-blue-950 hover:bg-blue-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-300">Admin sign in</a>
                @endauth
            </nav>
        </div>
    </header>

    <main id="main-content" tabindex="-1" class="mx-auto max-w-6xl px-6 py-10 focus:outline-none">
        {{ $slot }}
    </main>

    <footer class="border-t border-slate-300 bg-white">
        <div class="mx-auto max-w-6xl px-6 py-6 text-sm text-slate-700">Prototype only. Formal accessibility, privacy, procurement, and security review is required before deployment.</div>
    </footer>
</body>
</html>
Expose validation feedback in text

Server-side validation already blocks invalid records. The Blade page now needs to explain the failure when Laravel returns the form.

  • Select resources/views/announcements/create.blade.php in the Visual Studio Code Explorer.
  • Select from the opening x-public-layout element through the line immediately before the existing form.
  • Replace that selection with the following page introduction plus error summary:
<x-public-layout title="Publish announcement">
    <div class="mx-auto max-w-2xl">
        <p class="text-sm font-semibold uppercase tracking-wide text-blue-800">Authenticated publishing</p>
        <h1 class="mt-2 text-3xl font-bold tracking-tight text-slate-950">Publish an announcement</h1>
        <p id="form-instructions" class="mt-3 leading-7 text-slate-700">Both fields are required. Use plain language and do not include personal or sensitive information.</p>

        @if ($errors->any())
            <div role="alert" aria-labelledby="error-title" class="mt-6 rounded-md border-2 border-red-700 bg-red-50 p-4 text-red-950">
                <h2 id="error-title" class="font-bold">The announcement was not published.</h2>
                <ul class="mt-2 list-disc space-y-1 pl-6">
                    @foreach ($errors->all() as $error)
                        <li>{{ $error }}</li>
                    @endforeach
                </ul>
            </div>
        @endif

How Does the Error Summary Work?

  • The $errors->any() check renders the summary after validation fails.
  • The role="alert" value identifies the summary as important feedback.
  • The $errors->all() loop presents every validation problem in text.
  • The escaped {{ $error }} expression renders each message as text.
  • Save resources/views/announcements/create.blade.php.
  • Return to the authenticated publishing page.
  • Leave both fields blank.
  • Activate Publish announcement.

You should return to the form with a red summary titled The announcement was not published. The summary should identify both required fields.

Error Summary Missing?

  • Confirm the @if ($errors->any()) block appears above the form.
  • Check that the form still targets announcements.store.
  • Confirm the form still contains @csrf.

Ask for help with the missing summary:

The summary explains the overall failure. Field-level ARIA references connect each control to its instructions plus its own error message.

  • Select the complete existing form element in resources/views/announcements/create.blade.php.
  • Replace the selected form with the following completed form:
        <form method="POST" action="{{ route('announcements.store') }}" aria-describedby="form-instructions" class="mt-8 grid gap-6">
            @csrf
            <div>
                <label for="title" class="block font-bold text-slate-950">Title <span aria-hidden="true">*</span></label>
                <p id="title-help" class="mt-1 text-sm text-slate-700">Maximum 160 characters.</p>
                <input id="title" name="title" type="text" maxlength="160" required value="{{ old('title') }}" aria-invalid="@error('title') true @else false @enderror" aria-describedby="title-help @error('title') title-error @enderror" class="mt-2 w-full rounded-md border border-slate-500 bg-white px-3 py-3 text-slate-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900">
                @error('title')
                    <p id="title-error" class="mt-2 font-semibold text-red-800">{{ $message }}</p>
                @enderror
            </div>
            <div>
                <label for="summary" class="block font-bold text-slate-950">Summary <span aria-hidden="true">*</span></label>
                <p id="summary-help" class="mt-1 text-sm text-slate-700">Maximum 1,000 characters. Plain text only.</p>
                <textarea id="summary" name="summary" rows="7" maxlength="1000" required aria-invalid="@error('summary') true @else false @enderror" aria-describedby="summary-help @error('summary') summary-error @enderror" class="mt-2 w-full rounded-md border border-slate-500 bg-white px-3 py-3 text-slate-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900">{{ old('summary') }}</textarea>
                @error('summary')
                    <p id="summary-error" class="mt-2 font-semibold text-red-800">{{ $message }}</p>
                @enderror
            </div>
            <div class="flex flex-wrap gap-3">
                <button type="submit" class="rounded-md bg-blue-900 px-5 py-3 font-bold text-white hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900">Publish announcement</button>
                <a href="{{ route('home') }}" class="rounded-md border border-slate-500 bg-white px-5 py-3 font-bold text-slate-900 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900">Cancel</a>
            </div>
        </form>

How Does the Form Support Recovery?

  • The required indicators show that both controls need input.
  • The old('title') expression restores a valid title after another field fails.
  • The old('summary') expression restores a valid summary after another field fails.
  • Each aria-invalid value changes according to its matching validation result.
  • Each aria-describedby value connects the control to guidance plus its field error.
  • Save resources/views/announcements/create.blade.php.
  • Return to the publishing form.
  • Enter Road maintenance update in the Title field.
  • Leave the Summary field blank.
  • Activate Publish announcement.

You should see the valid title retained when the form returns. A field-specific message should appear below the blank summary.

  • Clear the retained title.
  • Leave the summary blank.
  • Activate Publish announcement again.

You should see the error summary plus a message beside each field. Both invalid controls expose aria-invalid="true" in the rendered markup.

Field Feedback Incomplete?

  • Confirm each @error directive uses its matching field name.
  • Check that title-error matches the title input description reference.
  • Check that summary-error matches the summary description reference.

Ask for help with incomplete field feedback:

✔️ Awesome, I've got everything!

Your publishing form now presents instructions plus visible validation feedback. Confirm that resources/views/announcements/create.blade.php is saved.

ⓧ I'd like to double check the full code

<x-public-layout title="Publish announcement">
    <div class="mx-auto max-w-2xl">
        <p class="text-sm font-semibold uppercase tracking-wide text-blue-800">Authenticated publishing</p>
        <h1 class="mt-2 text-3xl font-bold tracking-tight text-slate-950">Publish an announcement</h1>
        <p id="form-instructions" class="mt-3 leading-7 text-slate-700">Both fields are required. Use plain language and do not include personal or sensitive information.</p>

        @if ($errors->any())
            <div role="alert" aria-labelledby="error-title" class="mt-6 rounded-md border-2 border-red-700 bg-red-50 p-4 text-red-950">
                <h2 id="error-title" class="font-bold">The announcement was not published.</h2>
                <ul class="mt-2 list-disc space-y-1 pl-6">
                    @foreach ($errors->all() as $error)
                        <li>{{ $error }}</li>
                    @endforeach
                </ul>
            </div>
        @endif

        <form method="POST" action="{{ route('announcements.store') }}" aria-describedby="form-instructions" class="mt-8 grid gap-6">
            @csrf
            <div>
                <label for="title" class="block font-bold text-slate-950">Title <span aria-hidden="true">*</span></label>
                <p id="title-help" class="mt-1 text-sm text-slate-700">Maximum 160 characters.</p>
                <input id="title" name="title" type="text" maxlength="160" required value="{{ old('title') }}" aria-invalid="@error('title') true @else false @enderror" aria-describedby="title-help @error('title') title-error @enderror" class="mt-2 w-full rounded-md border border-slate-500 bg-white px-3 py-3 text-slate-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900">
                @error('title')
                    <p id="title-error" class="mt-2 font-semibold text-red-800">{{ $message }}</p>
                @enderror
            </div>
            <div>
                <label for="summary" class="block font-bold text-slate-950">Summary <span aria-hidden="true">*</span></label>
                <p id="summary-help" class="mt-1 text-sm text-slate-700">Maximum 1,000 characters. Plain text only.</p>
                <textarea id="summary" name="summary" rows="7" maxlength="1000" required aria-invalid="@error('summary') true @else false @enderror" aria-describedby="summary-help @error('summary') summary-error @enderror" class="mt-2 w-full rounded-md border border-slate-500 bg-white px-3 py-3 text-slate-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900">{{ old('summary') }}</textarea>
                @error('summary')
                    <p id="summary-error" class="mt-2 font-semibold text-red-800">{{ $message }}</p>
                @enderror
            </div>
            <div class="flex flex-wrap gap-3">
                <button type="submit" class="rounded-md bg-blue-900 px-5 py-3 font-bold text-white hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900">Publish announcement</button>
                <a href="{{ route('home') }}" class="rounded-md border border-slate-500 bg-white px-5 py-3 font-bold text-slate-900 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900">Cancel</a>
            </div>
        </form>
    </div>
</x-public-layout>
Rebuild and test the complete flow

The new focus utilities need to be included in the generated frontend assets. A keyboard-only test then proves that navigation plus error recovery remain observable without a mouse.

  • Switch to the second PowerShell terminal inside %USERPROFILE%\Herd\my-app.
  • Rebuild the frontend assets by running:
npm run build

What Does This Command Do?

The build processes the project styles plus JavaScript. It includes the new focus utilities used by the layout and publishing form.

  • Wait for the frontend build to finish.

You should see the build complete without an error. Your latest focus styles are now part of the local application assets.

Frontend Build Failing?

  • Confirm the terminal is inside %USERPROFILE%\Herd\my-app.
  • Check that the earlier development server still runs in its original PowerShell window.
  • Confirm the frontend dependencies from the project setup remain installed.

Ask for help with the failed build:

Before you begin this final check, do you expect every action plus every validation failure to remain understandable without a mouse?

  • Return to http://localhost:8000 in Microsoft Edge.
  • Refresh the public page.
  • Press Tab once from the top of the page.
  • Press Enter while Skip to main content has focus.
  • Press Tab through the available controls.
  • Press Shift+Tab to move backward through those controls.

You should bypass the repeated header when you activate the skip link. Each interactive control should display a visible outline when it receives keyboard focus.

  • Sign in with your local account if the header shows Admin sign in.
  • Use Tab to focus Publish announcement.
  • Press Enter to open the publishing page.
  • Refresh the publishing page.
  • Press Tab once from the top of the page.
  • Press Enter while the skip link has focus.

Focus should move to the publishing content. You should be able to reach every field plus every action with visible focus.

  • Use Tab to reach the submit button without entering text.
  • Press Enter to submit the blank form.

You should see a readable error summary plus a message beside each field. The form should retain any valid input when only one field fails.

Why Escaped Output Still Matters

Announcement content plus validation messages use escaped {{ }} output. The browser presents those values as text instead of interpreting them as page markup.

This local portal demonstrates selected accessibility techniques. It remains a prototype that requires formal review before deployment.

  • Enter Water service interruption in the Title field.
  • Enter Service will pause from 9 AM to 11 AM for planned maintenance. in the Summary field.
  • Use Tab to focus Publish announcement.
  • Press Enter.

You should return to the public page with the published status message. You should also see a new announcement card containing the submitted title plus summary.

Keyboard Flow Breaking?

  • Confirm the development server still runs in its original PowerShell window.
  • Check that the skip link target matches id="main-content" on the layout.
  • Confirm the publishing button remains inside the authenticated navigation branch.

Ask for help with the keyboard flow:

Strong finish. Civic Bulletin now supports a complete keyboard publishing loop with visible navigation plus readable error recovery.

Secret mission

Produce Accessibility Test Evidence

Turn Civic Bulletin's accessibility features into evidence another reviewer can repeat. You will test the complete keyboard journey, inspect both portal pages at 200% zoom, save two screenshots, and document every result that needs further review.

Clean Up Your Resources

Clean Up Your Resources

This Laravel project stays on your machine at no ongoing cost. Choose whether to keep everything available, pause development, or remove the local resources.

Resources you used:

  • The Laravel project folder at %USERPROFILE%\Herd\my-app, including its local configuration plus frontend dependencies.
  • The running local Laravel development process.
  • The MySQL schema named civic_bulletin, including its Laravel tables plus persisted announcements.
  • The local civic_portal@localhost application account.

What stays installed?

These cleanup options target the portal-specific resources. MySQL Community Server remains installed for future local projects.

Its Windows service also remains installed. Your development tools stay available for another Laravel project.

Keep everything running

No action is needed. Choose this if you want to continue publishing notices or reviewing the portal.

  • Leave the local Laravel development process running.
  • Keep the civic_bulletin schema available with its persisted announcements.
  • Keep the civic_portal@localhost account available for the application.
  • Retain your two Microsoft Edge screenshots plus the accessibility evidence note.

Pause - I'll come back to this later

Stop the local development process to free its terminal session. Your project files plus MySQL data remain ready for your next session.

  • Switch back to the PowerShell window running the local development server.
  • Press Ctrl+C to stop the process.

Your PowerShell prompt returns when the process stops. The portal is now paused while its announcements remain stored in MySQL.

  • Restart the portal later by running these commands:
cd ~\Herd
cd my-app
composer run dev

What do these commands do?

  • The first command moves PowerShell to the Herd folder.
  • The second command moves into the existing my-app folder.
  • The final command restarts the local development processes.
  • Return to Microsoft Edge after the development server starts.
  • Enter http://localhost:8000 in the address bar.

You should see Civic Bulletin with the announcements you published earlier. Your paused project is ready to use again.

Portal not loading after restart?

Confirm that the PowerShell window still shows the development process running.

Check that you restarted it from the existing my-app folder.

Help me diagnose why my local Laravel portal does not load after restarting it.

Delete - I don't want to use this again

Deleting these portal resources is permanent once the database is dropped. This removes the local application plus its stored announcements.

  • Confirm that any accessibility evidence you want to keep is stored outside %USERPROFILE%\Herd\my-app.
  • Switch back to the PowerShell window running the local development server.
  • Press Ctrl+C to stop the process.

The PowerShell prompt returns when the development process stops.

  • Use File Explorer to navigate to %USERPROFILE%\Herd.
  • Delete the my-app folder.

You should no longer see my-app inside the Herd folder.

  • Prepare the MySQL root password you created during setup.
  • Enter that password when the MySQL client prompts you.
  • Connect to MySQL as root by running these commands:
cd "C:\Program Files\MySQL\MySQL Server 8.4"
bin\mysql -u root -p

What does this command do?

The first line switches PowerShell to the default MySQL installation folder. The second line starts a password-protected root session.

  • Remove the portal schema plus its application account by entering these statements at the MySQL prompt:
DROP DATABASE civic_bulletin;
DROP USER 'civic_portal'@'localhost';
EXIT

What gets removed?

  • The first statement permanently removes the civic_bulletin schema plus every persisted announcement.
  • The second statement removes the local civic_portal@localhost account plus its privileges.
  • The final statement closes the MySQL session.

You should see both removal statements complete successfully. PowerShell becomes active again after the MySQL session closes.

  • Verify that the schema is gone by running this command:
bin\mysqlshow -u root -p

What does this check prove?

The command lists the databases that remain on your local MySQL server. The portal cleanup is complete when civic_bulletin is absent.

You should no longer see civic_bulletin in the database list. MySQL Community Server remains available for future local projects.

Having trouble deleting the resources?

If MySQL rejects the root sign-in, retry with the root password you created in MySQL Configurator.

If File Explorer cannot delete my-app, confirm that the local development process has stopped.

Help me troubleshoot deleting my local Civic Bulletin project or MySQL resources.

Nice Work!

Nice Work!

You did it! You built Civic Bulletin with Laravel as a local portal that carries authenticated announcements into a persistent public page. Its accessible publishing flow gives keyboard users visible navigation cues plus clear recovery from form errors.

What you learned:

  • Built a public information interface with Blade templates. Styled it with Tailwind CSS so keyboard focus stays visible across the responsive page. Added semantic landmarks plus a skip link for direct access to the main content.
  • Connected a dedicated MySQL schema to persisted notices through Eloquent. Protected publishing routes with authentication middleware plus CSRF protection. Rendered announcement text through Blade's escaped output.
  • Made validation errors perceivable through a text summary plus field-specific messages. Preserved valid input for quick correction. Verified keyboard access across every interactive control.
  • Secret Mission: Produced accessibility test evidence from a keyboard-only test. Saved two Microsoft Edge screenshots showing focus behavior plus readability at 200% zoom. Recorded unresolved risks without presenting the prototype as WCAG or DICT certified.

Ready to quiz yourself?