ForgeWire

A reactive controller-rendering protocol your app uses to update parts of a server-rendered page without a full reload. Your controllers stay the source of truth; ForgeWire handles the targeted updates between them and the browser.

Overview

ForgeWire is a protocol: a contract between your server-rendered controllers and a small client-side runtime. You mark a controller as reactive, annotate its state and actions, and mark the regions of the view you want to update. When the visitor interacts, the browser sends a wire request, your controller runs, and ForgeWire patches just those marked regions back into the page.

It is not a component framework that re-renders whole pages or swaps component lifecycles in the browser. There are no frontend-mounted components in the Vue/React sense — your PHP controller remains the single place logic lives, and the browser is a thin client following a well-defined protocol.

What it gives you

  • Reactive controllers with state
  • Targeted, partial DOM updates
  • Computed values & actions
  • Property validation
  • Events, flashes & redirects
  • Built-in security & checksums

Prerequisite: ForgeWire builds on the routing layer (wire requests are routed HTTP requests), so it needs the router capability present in your app.

How the Protocol Works

  1. A page renders with reactive "islands" — DOM regions marked as ForgeWire boundaries.
  2. The visitor clicks an element wired to an action (or types into a bound field).
  3. The client sends a wire request (a POST to /__wire with an X-ForgeWire header) carrying the action, parameters, and a signed snapshot of the controller state.
  4. Your reactive controller is hydrated with that state, the action runs, and its view region is re-rendered.
  5. The response returns the updated HTML (and any events, flashes, or redirects you requested), and the client patches just those islands into the page — no full reload.

Because state round-trips through your session and is signed, the server stays authoritative; the client is never asked to "own" application state.

Installation

php forge.php package:install-module --module=ForgeWire

Installing links the client assets (the forgewire.js runtime) so they're served to your pages, and wires up the middleware that handles wire requests. The runtime is injected into your responses automatically.

The Reactive Controller

A reactive controller is a normal controller marked with the Reactive attribute. Compose the helper trait to gain the response verbs (events, flashes, redirects):

use Modules\ForgeWire\Attributes\Reactive;
use Modules\ForgeWire\Traits\ReactiveEndpointHelper;

#[Reactive]
final class CounterController
{
    use ReactiveEndpointHelper;

    #[State]
    public int $count = 0;

    #[Action]
    public function increment(): void
    {
        $this->count++;
    }
}

The controller is still a controller — it renders a view like any other. ForgeWire only adds the reactive lifecycle: restore state, run the action, re-render the marked regions.

State, Computed & Actions

Three annotations shape the controller's reactive surface:

  • #[State] — a property that round-trips with the client. State is hydrated from your session on each request (a shared flag makes a value shared across the component's islands).
  • #[Computed] — a method whose result is memoized for the request; handy for derived values you use in the view.
  • #[Action] — a method callable from the client, with arguments supplied by the browser. Actions are the only entry points the frontend may invoke.
use Modules\ForgeWire\Attributes\State;
use Modules\ForgeWire\Attributes\Computed;
use Modules\ForgeWire\Attributes\Action;

#[Reactive]
final class CartController
{
    use ReactiveEndpointHelper;

    #[State]
    public array $items = [];

    #[Computed]
    public function total(): float
    {
        return array_sum(array_column($this->items, 'price'));
    }

    #[Action]
    public function add(string $sku, int $qty = 1): void
    {
        $this->items[] = ['sku' => $sku, 'price' => price($sku), 'qty' => $qty];
    }
}

Because only #[Action] methods are callable from the browser, your controller's other logic can't be invoked directly — one of the ways the surface stays small and safe.

Validation

Validate state properties with the Validate attribute — rules as an array or a pipe-separated string, with custom messages. Invalid input surfaces as a validation failure instead of an action running on bad data:

use Modules\ForgeWire\Attributes\State;
use Modules\ForgeWire\Attributes\Validate;

#[Reactive]
final class SignupController
{
    use ReactiveEndpointHelper;

    #[State]
    #[Validate(rules: ['required', 'email'], messages: ['email' => 'Enter a valid email.'])]
    public string $email = '';

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

Islands in the View

Reactivity happens inside explicit islands. Mark a region with the fw_id() helper. Without a marked island there is no reactivity — the boundary is required. A page can hold several independent islands, each with its own reactive state.

// counter view
<div <?= fw_id('main-counter') ?> class="counter-box">
    <h1>Count: <?= $count ?></h1>
    <button fw:click="increment">Add One</button>
</div>

<!-- a second, independent island on the same page -->
<div <?= fw_id('info-panel') ?> class="info-box">
    <p fw:poll.5s>Updated at: <?= date('H:i:s') ?></p>
</div>

Interactions are wired with element directives — fw:click, fw:submit, fw:model (two-way-ish field binding), fw:param (action arguments), fw:depends, and fw:event. The client observes these and issues wire requests.

Cleaning up unused islands keeps memory bounded; an automatic cleanup service and a CLI tool handle stale components.

Shared State Between Islands

Because the controller is the single source of truth, two islands on the same page don't need to pass messages back and forth to share a value. They are simply different views of the same controller state — and the server keeps all of them in sync for you.

A #[State] property marked shared lives in the controller and is stored once. Any island can read it, and when an action on one island changes it, the server re-renders every other island that depends on that value before patching them back into the page. The islands converge automatically — no custom event wiring, and no chance of one island showing stale state because it describes its own copy.

use Modules\ForgeWire\Attributes\Reactive;
use Modules\ForgeWire\Attributes\State;

#[Reactive]
final class DashboardController
{
    use ReactiveEndpointHelper;

    #[State(shared: true)]
    public string $region = 'north';
}

How does the server know which islands to update? Declare the dependencies with the fw:depends directive — a comma-separated list of the shared state names an island reads:

<div <?= fw_id('region-picker') ?>>
    <button fw:click="setRegion" fw:param-region="north">North</button>
    <button fw:click="setRegion" fw:param-region="south">South</button>
</div>

<!-- this island reads region; refresh it whenever region changes -->
<div <?= fw_id('region-report') ?> fw:depends="region">
    <p>Showing: <?= $region ?></p>
</div>

Clicking North changes the shared region. Because region-report declares it depends on region, the server re-renders that island with the new value automatically. The browser and any shared-state bookkeeping stay out of it — the controller already holds the truth, and the server simply republishes it to the islands that care.

Responding: Events & Redirects

Inside an action, tell the client to do more than patch HTML — queue a redirect, show a flash, or emit an event other islands (or other page code) can listen for:

#[Action]
public function save(): void
{
    $this->account->save();

    $this->flash('success', 'Saved!');
    $this->dispatch('account.updated', ['id' => $this->account->id]);
    $this->redirect('/dashboard');
}

These come from the WithWireResponse trait composed through ReactiveEndpointHelper. Redirects can carry a small delay; flashes and events are delivered back in the wire response.

Polling

For live-ish content, an island can poll the server on an interval using the fw:poll directive with a duration suffix:

<div <?= fw_id('scores') ?>>
    <ul fw:poll.10s>
        <?php foreach ($scores as $s): ?>
            <li><?= $s ?></li>
        <?php endforeach; ?>
    </ul>
</div>

Polling is smart about visibility: the runtime pauses polling while an island is off-screen and resumes when it's visible again.

Security

The protocol is built around server authority, and several guards are on by default:

  • Every state round-trip is signed with a checksum; tampered state or calls to non-action methods are rejected.
  • Wire requests carry a CSRF token, checked on every request.
  • Only #{Action} methods are callable from the client.
  • Event names, CSS selectors, and redirect URLs are validated and restricted (same-origin or absolute paths) before the client acts on them.

Configuration

Behavior is tuned with a couple of environment variables, both with sensible defaults:

# Serve the minified client runtime (recommended in production)
FORGE_WIRE_USE_MINIFIED=true

# How long an idle reactive component is kept before cleanup (seconds)
FORGEWIRE_COMPONENT_TTL_SECONDS=1800

Use FORGE_WIRE_USE_MINIFIED=false during development to keep the readable runtime for debugging.

CLI Commands

# Remove stale reactive components
php forge.php modules:forgewire:cleanup

# Regenerate the minified client runtime
php forge.php modules:forgewire:minify

The cleanup command prunes components whose session state is stale, and the minify command regenerates the compressed client script after you change the runtime sources.