A full interactive PTY terminal for Laravel + Filament, streamed over WebSocket. The browser side is a ghostty-web canvas — a real terminal emulator compiled to WASM — so full-screen TUI apps like vim, htop, nano, tmux, and artisan tinker just work. Connect to the local machine or to remote servers over SSH (password or key auth).
This package was extracted from mwguerra/web-terminal. That package keeps its dual-mode terminal (Classic command-by-command execution + Stream); this one is Stream-only, fully renamed and namespaced so the two can be installed side-by-side in the same application.
| Version | Filament | Laravel | Livewire | PHP |
|---|---|---|---|---|
| 1.x | 5.x | 12.x–13.x | 4.x | 8.3+ |
proc_open PTY or a phpseclib SSH shell); keystrokes and output stream as raw bytes, no Livewire round tripsSIGWINCH), full-screen TUI appsframeless() drops the header (actions float over the canvas) and squareCorners() drops the outer radius, so terminals can tile edge-to-edge in grid layoutsTerminalWorkspace splits into arbitrarily nested panes at runtime with configurable keyboard shortcuts (prefix Ctrl+B by default): split, close, zoom, directional focus, keyboard + drag resize; every pane is an isolated PTYTerminalGrid composes fixed multi-terminal dashboards in a flush CSS gridconfirmBeforeRun confirmation gatesTerminalConnectedEvent/TerminalDisconnectedEvent, database logging, and a ready-made Filament Terminal Logs resource with stats widgetsWarning — this is real shell access
This package hands the browser a live shell on a real server. Anything the PHP user (or the SSH user) can do, the terminal user can do: modify files, change configuration, stop services, delete data.
Unlike
mwguerra/web-terminal's Classic mode, this package intentionally has no command whitelisting — a raw PTY cannot be meaningfully whitelisted (shells have aliases, expansions, editors that spawn shells, …), so pretending otherwise would be false security. Access control is the boundary:
- Restrict the pages that embed a terminal to trusted technical personnel (Filament panel auth, policies).
- Define a
useStreamTerminalGate — it is checked before every WebSocket token is issued.- Keep the WebSocket server bound to
127.0.0.1behind a reverse proxy, or firewall its port.- Keep
stream.allowed_originsaccurate — browser WebSocket handshakes from other origins are rejected with 403 before the auth token is consumed.- Keep connection logging enabled for an audit trail.
Use at your own risk. The authors are not responsible for any damage, data loss, or security incidents resulting from the use of this package.
composer require mwguerra/web-terminal-stream
The WebSocket server's ReactPHP stack (react/socket, react/event-loop, ratchet/rfc6455) is a hard requirement and installs automatically.
php artisan terminal-stream:install
The installer publishes the config file and the terminal_stream_logs migration, and can scaffold a custom Terminal page and/or TerminalLogs resource into your app.
All flags:
# Choose components explicitly (non-interactive)
php artisan terminal-stream:install --config --migration --no-interaction
# Publish + run the migration
php artisan terminal-stream:install --config --migration --migrate --no-interaction
# Multi-tenant migration (adds tenant_id column) / explicitly without
php artisan terminal-stream:install --with-tenant
php artisan terminal-stream:install --no-tenant
# Publish Blade views for customization
php artisan terminal-stream:install --views
# Scaffold a custom Terminal page / TerminalLogs resource in your app
php artisan terminal-stream:install --page --resource --panel=admin
# Overwrite existing files
php artisan terminal-stream:install --force
php artisan vendor:publish --tag=web-terminal-stream-config
php artisan vendor:publish --tag=web-terminal-stream-views # optional
php artisan vendor:publish --tag=web-terminal-stream-lang # optional
php artisan migrate
The terminal cannot work without the PTY bridge. Start it from the same application (it shares the app's cache and APP_KEY with the HTTP side):
php artisan terminal-stream:serve # default 127.0.0.1:8090
php artisan terminal-stream:serve --host=0.0.0.0 --port=8090
For production, supervise it:
# Supervisor
[program:terminal-stream-serve]
command=php /path/to/artisan terminal-stream:serve
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/terminal-stream-serve.log
# systemd
[Unit]
Description=Web Terminal Stream WebSocket Server
After=network.target
[Service]
ExecStart=/usr/bin/php /path/to/artisan terminal-stream:serve
Restart=always
User=www-data
[Install]
WantedBy=multi-user.target
When your app is served over HTTPS, browsers require the terminal WebSocket to be WSS. Two options:
Reverse proxy (recommended): keep the server on 127.0.0.1:8090 and terminate TLS at nginx/Caddy, then point the package at the public URL:
WEB_TERMINAL_STREAM_WEBSOCKET_URL=wss://your-domain.test/terminal-ws
location /terminal-ws {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
Direct TLS: give the server a certificate and it serves WSS itself:
WEB_TERMINAL_STREAM_SSL_CERT=/path/to/cert.crt
WEB_TERMINAL_STREAM_SSL_KEY=/path/to/cert.key
WEB_TERMINAL_STREAM_WEBSOCKET_URL=wss://your-domain.test:8090
The server validates the Origin header of every browser WebSocket handshake against stream.allowed_origins (default: [env('APP_URL', 'http://localhost')]). A handshake from an origin not on the list is rejected with HTTP 403 — and logged at warning level — before the single-use auth token is consumed, so a malicious page in a logged-in user's browser cannot open a socket even if it managed to obtain a token.
Matching is exact on the normalized origin (scheme + case-insensitive host + port, with default ports filled in per scheme — https://app.test and https://app.test:443 are the same origin). Requests without an Origin header (non-browser clients, CLI tooling) are allowed through — browsers always send Origin on WebSocket upgrades, so this does not weaken browser-facing CSRF protection; the encrypted single-use token remains the auth gate.
// config/web-terminal-stream.php
'stream' => [
'allowed_origins' => [
env('APP_URL', 'http://localhost'),
'https://admin.example.com',
],
],
A literal '*' entry disables the check entirely — an escape hatch for reverse-proxy setups that strip or rewrite the Origin header. An empty (or missing) list also disables the check, so a published config predating this key keeps working; republish the config to get the secure default.
Because this is an array, it is config-file-only — there is no WEB_TERMINAL_STREAM_* env var for it (env vars carry scalars). Reference env('APP_URL') or your own env-driven values inside the published config file instead.
Beyond who may open a terminal (the useStreamTerminal gate), the security config block constrains what a terminal may connect to. It is enforced at token issuance and re-checked on the server before a PTY starts, so a token minted for a disallowed target is refused even if issuance were bypassed.
// config/web-terminal-stream.php
'security' => [
// Allow local-shell terminals at all. Set false to expose SSH only, so the
// app host's own shell is never reachable through the browser.
'allow_local' => env('WEB_TERMINAL_STREAM_ALLOW_LOCAL', true),
// SSH destination allow-list. Empty = any host (NOT recommended in
// production — the server becomes an SSH/SSRF pivot). List exact
// hostnames/IPs, optionally as "host:port" to pin the port.
'ssh_allowed_hosts' => ['bastion.internal', 'db-1.internal:2222'],
// Rate limit for the ws-token issuance route: "<maxAttempts>,<minutes>".
'token_rate_limit' => env('WEB_TERMINAL_STREAM_TOKEN_RATE_LIMIT', '30,1'),
],
phpseclib does not verify server host keys by default, so with mode left at off an outbound SSH session is open to a man-in-the-middle. Turn it on with either pinned fingerprints or an OpenSSH known_hosts file:
'security' => [
'ssh_host_key' => [
'mode' => env('WEB_TERMINAL_STREAM_SSH_HOSTKEY_MODE', 'off'), // off | known_hosts | fingerprints
// mode = known_hosts
'known_hosts_path' => env('WEB_TERMINAL_STREAM_SSH_KNOWN_HOSTS', '/etc/ssh/ssh_known_hosts'),
// mode = fingerprints (per host, or "host:port"; SHA256 or MD5)
'fingerprints' => [
'bastion.internal' => 'SHA256:l1C7yZJ+mCVTwIunDQ0ZzytglTeg5SloW66Iwm874pc',
],
],
],
A key that does not match aborts the connection before authentication.
The server is a single long-running process holding one PTY per connection. Cap it so a runaway client cannot exhaust the host:
'stream' => [
'max_connections' => env('WEB_TERMINAL_STREAM_MAX_CONNECTIONS', 100), // total live PTYs (0 = unlimited)
'max_sessions_per_user' => env('WEB_TERMINAL_STREAM_MAX_SESSIONS_PER_USER', 10),
'max_handshake_bytes' => env('WEB_TERMINAL_STREAM_MAX_HANDSHAKE_BYTES', 16384),
'max_session_lifetime' => 3600, // PTYs older than this are reaped and their sockets closed
],
APP_KEY. The HTTP app issues the single-use token and caches the (encrypted) connection config; terminal-stream:serve pulls it back. They must share the same cache store and APP_KEY — run the server from the same deployment, and do not use the array cache driver. A per-request cache (or a mismatched key) means every handshake fails.ext-posix / ext-pcntl are missing or the host is not Linux (local-shell PTY resizing needs /proc + stty). SSH connections are unaffected by these.terminal_stream_logs; schedule terminal-stream:logs:cleanup (see Retention & cleanup).host:port; there is no HTTP health endpoint.Two different things bound this server, and they have different fixes.
Output delivery — fixed in 1.1.1. Until then the server polled every session every 10ms and each SSH read waited out its own timeout, so an idle terminal cost as much as a busy one and latency grew with the number of open terminals. Sessions are now registered with the event loop and wake it only when bytes arrive. Measured at 100 concurrent sessions: round-trip p50 went 1877ms → 264ms. Nothing to configure — stream.io_mode defaults to event, with poll kept as an escape hatch.
Connection establishment — bounded, not removed. A session's SSH connect + auth runs synchronously on its event loop, so while one session connects, every other session on that loop stalls. Workers bound the damage:
php artisan terminal-stream:serve --workers=4 # or stream.workers
Each worker is an independent server sharing the port through SO_REUSEPORT, supervised by the parent (a dead worker is replaced; SIGTERM shuts the fleet down gracefully). With N workers a connect storm freezes 1/N of the fleet, and phpseclib's crypto spreads across cores. Start with the number of cores you are willing to give the terminal server.
Because workers share nothing, max_connections and max_sessions_per_user are per worker: 4 × 100 is a fleet ceiling of 400.
When to add workers. Watch sweep_ms and connect_ms in the health widget below, not just the session count. The hard cap refuses at 100%, but the experience degrades well before it, so the useful trigger is sustained saturation above ~60%, or a connect_ms p95 that users would notice as a freeze.
Every worker publishes a snapshot — live sessions by type, SSH connect p50/p95, loop sweep p50/p95, refusals by reason, uptime — which the app reads without ever talking to a server process:
use MWGuerra\WebTerminalStream\Filament\Widgets\TerminalServerHealthWidget;
// Any Filament panel:
->widgets([TerminalServerHealthWidget::class])
use MWGuerra\WebTerminalStream\Metrics\MetricsReader;
$fleet = MetricsReader::make()->read(); // aggregate, for your own dashboards/alerts
Read the numbers this way:
connect_ms is not the latency of the person connecting — it is how long everyone else on that worker is frozen. It is the metric that makes the synchronous-connect limit visible.sweep_ms is the loop's own heartbeat. Climbing sweeps mean a worker running out of headroom, before any user complains.refused is capacity actually denied to a user. It should be zero.null, not 0 — an unmeasured latency must not read as an excellent one.| Variable | Config key | Default |
|---|---|---|
WEB_TERMINAL_STREAM_RATCHET_HOST |
stream.ratchet_host |
127.0.0.1 |
WEB_TERMINAL_STREAM_RATCHET_PORT |
stream.ratchet_port |
8090 |
WEB_TERMINAL_STREAM_WEBSOCKET_URL |
stream.websocket_url |
null (built from host/port) |
WEB_TERMINAL_STREAM_SSL_CERT |
stream.ssl_cert |
null |
WEB_TERMINAL_STREAM_SSL_KEY |
stream.ssl_key |
null |
WEB_TERMINAL_STREAM_SHELL |
stream.shell |
/bin/bash |
WEB_TERMINAL_STREAM_CWD |
stream.working_directory |
null (falls back to getcwd()) |
WEB_TERMINAL_STREAM_LOGGING |
logging.enabled |
true |
WEB_TERMINAL_STREAM_LOG_CONNECTIONS |
logging.log_connections |
true |
WEB_TERMINAL_STREAM_LOG_DISCONNECTIONS |
logging.log_disconnections |
true |
WEB_TERMINAL_STREAM_LOG_ERRORS |
logging.log_errors |
true |
WEB_TERMINAL_STREAM_MAX_OUTPUT_LOG |
logging.max_output_length |
10000 |
WEB_TERMINAL_STREAM_LOG_RETENTION |
logging.retention_days |
90 |
WEB_TERMINAL_STREAM_SHORTCUTS |
workspace.shortcuts.enabled |
true |
WEB_TERMINAL_STREAM_ALLOW_LOCAL |
security.allow_local |
true |
WEB_TERMINAL_STREAM_TOKEN_RATE_LIMIT |
security.token_rate_limit |
30,1 |
WEB_TERMINAL_STREAM_SSH_HOSTKEY_MODE |
security.ssh_host_key.mode |
off |
WEB_TERMINAL_STREAM_SSH_KNOWN_HOSTS |
security.ssh_host_key.known_hosts_path |
null |
WEB_TERMINAL_STREAM_MAX_CONNECTIONS |
stream.max_connections |
100 |
WEB_TERMINAL_STREAM_MAX_SESSIONS_PER_USER |
stream.max_sessions_per_user |
10 |
WEB_TERMINAL_STREAM_MAX_HANDSHAKE_BYTES |
stream.max_handshake_bytes |
16384 |
WEB_TERMINAL_STREAM_IO_MODE |
stream.io_mode |
event |
WEB_TERMINAL_STREAM_BACKSTOP_SWEEP |
stream.backstop_sweep_seconds |
0.25 |
WEB_TERMINAL_STREAM_WORKERS |
stream.workers |
1 |
WEB_TERMINAL_STREAM_METRICS_INTERVAL |
stream.metrics_interval_seconds |
5 |
WEB_TERMINAL_STREAM_METRICS_STALE_AFTER |
metrics.stale_after_seconds |
30 |
Non-env config keys: stream.max_session_lifetime (default 3600 — stale PTYs older than this are killed by the server's cleanup pass), stream.signed_url_ttl (default 300 — lifetime of a WebSocket auth token), stream.allowed_origins (default [env('APP_URL', 'http://localhost')] — see the Origin allow-list section; it's an array, so config-file-only), security.ssh_allowed_hosts and security.ssh_host_key.fingerprints (arrays — see Connection policy and SSH host-key verification).
use MWGuerra\WebTerminalStream\WebTerminalStreamPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugins([
WebTerminalStreamPlugin::make(),
]);
}
The plugin registers a Terminal page and a Terminal Logs resource. Configure it fluently:
WebTerminalStreamPlugin::make()
// Navigation
->terminalNavigation(icon: 'heroicon-o-command-line', label: 'Console', sort: 10, group: 'Tools')
->terminalLogsNavigation(icon: 'heroicon-o-document-text', label: 'Console Logs', sort: 11, group: 'Tools')
// Disable pieces
->withoutTerminalPage()
->withoutTerminalLogs()
// Or register only specific components
->components([
\MWGuerra\WebTerminalStream\Filament\Resources\TerminalLogResource::class,
]);
WebTerminalStreamPlugin::current() returns the registered instance from inside pages/components.
WebTerminalStream::make() is the public entry point. It works in any Filament schema (pages, forms):

use MWGuerra\WebTerminalStream\Schemas\Components\WebTerminalStream;
public function schema(Schema $schema): Schema
{
return $schema->components([
WebTerminalStream::make()
->key('app-terminal')
->local()
->workingDirectory(base_path())
->height('500px')
->title('Application Console')
->log(enabled: true, connections: true, identifier: 'app-terminal'),
]);
}
For a custom page, extend the package page and override schema() (or run php artisan terminal-stream:make-page):
php artisan terminal-stream:make-page ServerConsole --panel=admin --key=server-console
Mount the Livewire component directly:
<livewire:web-terminal-stream
:connection-config="['type' => 'local']"
height="400px"
title="Terminal"
/>
Or use the builder in any Blade view:
use MWGuerra\WebTerminalStream\Livewire\TerminalBuilder;
{!! (new TerminalBuilder)
->local()
->title('Console')
->height('400px')
->render() !!}
TerminalBuilder shares the exact same fluent API as the schema component (same traits) plus key(string) for a stable Livewire key.
// Local shell
WebTerminalStream::make()->local()
// SSH with password (named parameters)
WebTerminalStream::make()->ssh(
host: '192.168.1.100',
username: 'deploy',
password: 'secret',
port: 22,
)
// SSH with private key (+ optional passphrase)
WebTerminalStream::make()->ssh(
host: 'prod.example.com',
username: 'deploy',
privateKey: file_get_contents('/path/to/id_ed25519'),
passphrase: 'optional-passphrase',
)
// SSH with an array (extra keys: timeout, working_directory, environment)
WebTerminalStream::make()->ssh([
'host' => 'prod.example.com',
'username' => 'deploy',
'private_key' => Storage::get('keys/deploy'),
'port' => 2222,
'working_directory' => '/var/www',
'environment' => ['APP_ENV' => 'production'],
])
// SSH with a Closure — resolved at render time, credentials never sit in the schema
WebTerminalStream::make()->ssh(fn () => [
'host' => config('servers.prod.host'),
'username' => config('servers.prod.user'),
'private_key' => decrypt(config('servers.prod.key')),
])
// Value object
use MWGuerra\WebTerminalStream\Data\ConnectionConfig;
WebTerminalStream::make()->connection(
ConnectionConfig::sshWithKey(host: 'example.com', username: 'deploy', privateKey: $key)
)
ConnectionConfig has static factories local(), sshWithPassword(), sshWithKey() and validates required fields.
| Method | Description | Default |
|---|---|---|
key(string) |
Stable wire:key (auto-generated web-terminal-stream-XXXXXXXX otherwise) |
random |
local() |
Local shell connection | this is the default |
ssh(...) |
SSH connection — named params, array, or Closure | — |
connection(array|Closure|ConnectionConfig) |
Set the raw connection config | ['type' => 'local'] |
workingDirectory(string|Closure|null) |
Initial working directory for the shell | null |
height(string|Closure) |
Terminal height (CSS value) | '400px' |
title(string|Closure) |
Header title | 'Terminal' |
chrome(TerminalChrome|Closure) |
Full, Minimal (no window dots), or None (no header) |
Full |
frameless() |
Shorthand for chrome(TerminalChrome::None) |
— |
squareCorners(bool|Closure) |
Drop outer border-radius for flush grid tiling | false |
theme(array|Closure) |
ghostty-web theme (background, foreground, fontSize, palette…) |
[] |
scripts(array|Closure) |
Script definitions for the header dropdown | [] |
log(enabled:, connections:, identifier:, metadata:) |
Per-terminal logging overrides (see Logging) | config defaults |
logMetadata(array|Closure) |
Metadata attached to every log entry | [] |
connectionBehavior(ConnectionBehavior|Closure) |
Manual, Auto, or Always — see Connection behavior |
Always |
connectionBehavior() controls when the WebSocket (and therefore the PTY) opens and what connection UI the user sees:

use MWGuerra\WebTerminalStream\Enums\ConnectionBehavior;
WebTerminalStream::make()
->local()
->connectionBehavior(ConnectionBehavior::Manual)
Always (default) — auto-connects when the terminal scrolls into view; no connection controls. This matches the package's original always-auto-connect behavior, so existing code is unaffected.Auto — auto-connects on visibility and shows a connect/disconnect toggle. After a disconnect, a centered Reconnect affordance appears over the canvas.Manual — nothing happens on mount: no canvas boot, no WebSocket, no PTY, and no connection log rows. A centered, theme-colored Connect affordance sits in the terminal body; clicking it fetches a token, opens the socket, and swaps in the live canvas. A dashboard full of Manual panes costs zero server processes until someone actually opens one.The connect/disconnect toggle follows TerminalChrome: it renders as a header action when a header exists (Full/Minimal) and joins the floating overlay controls when frameless(). Disconnecting closes the WebSocket cleanly from the client (close code 1000); the server terminates the PTY on the socket close, exactly as it does for navigation teardown.
TerminalChrome controls how much UI surrounds the canvas:
Full — header with macOS-style window dots, title, and action buttons.Minimal — header without the dots.None — no header at all; the actions (scripts, copy, info, connect toggle) float over the canvas top-right.Combined with squareCorners(), frameless terminals tile edge-to-edge — the building block for tmux-like multi-pane layouts.
TerminalGridTerminalGrid lays out multiple terminals in a flush CSS grid — the tmux look, without manual plumbing:

use MWGuerra\WebTerminalStream\Enums\ConnectionBehavior;
use MWGuerra\WebTerminalStream\Schemas\Components\TerminalGrid;
use MWGuerra\WebTerminalStream\Schemas\Components\WebTerminalStream;
TerminalGrid::make()
->columns(2) // Filament-style, responsive arrays work too
->height('600px') // rows share it equally
->connectionBehavior(ConnectionBehavior::Manual) // panes connect on click, not on load
->panes([
WebTerminalStream::make()->key('pane-1')->local(),
WebTerminalStream::make()->key('pane-2')->local(),
WebTerminalStream::make()->key('pane-3')->ssh(host: 'staging', username: 'deploy', privateKey: $key),
WebTerminalStream::make()->key('pane-4')->local(),
])
What the grid does for you:
frameless() + squareCorners() automatically. A pane that explicitly set its own chrome()/squareCorners() keeps its setting.columns(int|array) — Filament-style responsive columns (default 2), e.g. ->columns(['md' => 2, 'xl' => 3]).paneGap(int $px) — pixel gap between panes. 0 (default) is the flush tmux look; ->paneGap(1) renders 1px dividers via the grid container background (override the color with the --wts-grid-divider CSS variable).height(string) — grid height; rows share it equally and panes without an explicit height() stretch to fill their row.connectionBehavior(...) — forwarded to every pane that didn't set its own, so a dashboard of Manual panes doesn't spawn N PTYs on page load.:focus-within; color via --wts-grid-focus-ring).->key()s when you need stable identities.Manual composition still works — TerminalGrid is sugar; arranging frameless()->squareCorners() terminals inside your own Filament\Schemas\Components\Grid (or any layout) remains fully supported. For runtime splitting (the real tmux experience), use TerminalWorkspace below.
TerminalWorkspaceTerminalWorkspace is one terminal that splits into arbitrarily nested panes at runtime, driven by configurable keyboard shortcuts — a tmux session in your Filament panel. Every pane is a fully isolated terminal: its own WebSocket, its own PTY, zero interference with siblings.

use MWGuerra\WebTerminalStream\Data\Keymap;
use MWGuerra\WebTerminalStream\Schemas\Components\TerminalWorkspace;
TerminalWorkspace::make()
->ssh(host: 'staging', username: 'deploy', privateKey: $key)
->height('70vh')
->maxPanes(6)
The workspace starts with a single pane described by the same fluent API as WebTerminalStream (connection, theme(), scripts(), log(), connectionBehavior()). New panes clone the pane they were split from (tmux semantics) — or use a template:
TerminalWorkspace::make()
->ssh(host: 'prod', username: 'deploy', privateKey: $key) // first pane
->defaultPane(fn (TerminalBuilder $pane) => $pane->local()->title('Scratch')) // every split
Press the prefix (Ctrl+B), then:
| Key | Action |
|---|---|
% |
Split side-by-side |
" |
Split stacked |
x |
Close the focused pane |
z |
Zoom the focused pane fullscreen (toggle; siblings stay live) |
arrows / h j k l |
Move focus between panes |
Ctrl+arrows |
Resize the focused pane |
| prefix again | Send a literal prefix byte to the shell |
While the prefix is armed, the workspace shows a badge and a ring; unbound keys are swallowed (tmux fidelity) and the armed state times out after 1.5s. Dividers between panes are also drag-resizable with the pointer.
Fluent (wins) → config/web-terminal-stream.php workspace.shortcuts → tmux preset:
use MWGuerra\WebTerminalStream\Data\Keymap;
use MWGuerra\WebTerminalStream\Enums\PaneAction;
TerminalWorkspace::make()
->local()
->keymap(
Keymap::tmux()
->prefix('ctrl+a') // screen-style leader
->bind(PaneAction::SplitVertical, '|', 'v') // multiple keys per action
->unbind(PaneAction::ClosePane) // disable an action
)
->shortcuts(false) // or kill all shortcuts without losing the map
Key strings are lowercase, +-joined modifiers (ctrl, alt, shift, meta) plus a KeyboardEvent.key value: 'ctrl+b', 'shift+arrowleft', '%'. The same shape works in the config file, keyed by the PaneAction values.
| Method | Description | Default |
|---|---|---|
local() / ssh(...) / connection(...) |
Connection of the first pane (and the clone base) | local |
height(string|Closure) |
Workspace height; panes fill their computed rects | '600px' |
theme() / scripts() / log() / connectionBehavior() |
Forwarded to every pane | — |
maxPanes(int|Closure) |
Hard pane ceiling, enforced server-side on every split | workspace.max_panes (9) |
keymap(Keymap|array|Closure) |
Shortcut map | config → tmux preset |
shortcuts(bool|Closure) |
Enable/disable all shortcuts | true |
defaultPane(Closure) |
Template for newly split panes (receives a fresh TerminalBuilder) |
clone split source |
Security model: the browser can only ever send a pane id, an orientation, and divider ratios. A new pane's connection config is derived server-side from Livewire-locked state (the split source or the build-time template) — never from client input — and every split re-checks the useStreamTerminal gate. Config knobs: workspace.max_panes, workspace.min_pane_ratio (a pane can't shrink below this share), workspace.resize_step (keyboard resize increment).
Planned next increments (not in this release): per-user layout persistence.
TerminalDashboardTerminalDashboard is a roster of named terminal sources (each a distinct connection), each opened or closed by a button. The open panes are auto-arranged by how many are open — you choose the layout per count:

use MWGuerra\WebTerminalStream\Schemas\Components\TerminalDashboard;
use MWGuerra\WebTerminalStream\Schemas\Components\WebTerminalStream;
TerminalDashboard::make()
->maxOpen(4)
->sources([
'web' => WebTerminalStream::make()->title('Web')->ssh(host: 'web-01', username: 'deploy', privateKey: $key),
'db' => WebTerminalStream::make()->title('Database')->ssh(host: 'db-01', username: 'deploy', privateKey: $key),
'cache' => WebTerminalStream::make()->title('Cache')->ssh(host: 'cache-01', username: 'deploy', privateKey: $key),
'queue' => WebTerminalStream::make()->title('Queue')->ssh(host: 'queue-01', username: 'deploy', privateKey: $key),
])
->defaultOpen(['web']) // which start open (default: the first)
->arrangement([ // how the space splits, per open-pane count
2 => 'columns',
3 => 'main-left',
4 => 'tiled',
], default: 'tiled')
->theme(TokyoNight::make()) // optional: forwarded to panes + dividers
LayoutTree::arrange()): tiled (2 = columns; 3 = one tall left + two stacked right; 4 = even 2×2 grid), columns (even side-by-side), rows (even stacked), main-left (big left + stacked right), main-top. All produce even ratios. The arrangement map picks a preset per count; anything unlisted uses the default.maxOpen is capped at 4 and enforced server-side; opening re-checks the useStreamTerminal gate. Each source's connection config stays server-side in the connection vault — the browser only ever holds an opaque handle.Planned next increment (not in this release): per-user layout persistence, shared with the workspace.
theme() accepts a TerminalTheme object (recommended) or a raw ghostty colors array. The theme object controls the terminal font, colors, and the pane divider styling in one place, with fluent partial overrides that keep every other default:

use MWGuerra\WebTerminalStream\Themes\TerminalTheme;
WebTerminalStream::make()
->local()
->theme(
TerminalTheme::make()
->fontFamily('JetBrains Mono, monospace')
->fontSize(14)
->background('#1a1b26')
->foreground('#a9b1d6')
)
Shipped presets — start from one and tweak only what you need:
use MWGuerra\WebTerminalStream\Themes\TokyoNight;
TerminalWorkspace::make()
->local()
->theme(TokyoNight::make()->fontSize(15)->dividerWidth(2)) // keeps every other TokyoNight default
Themes\TokyoNight and Themes\Dracula are included.
Ship your own theme as a subclass — override the defaults you care about, inherit the rest; it stays fluently tweakable:
namespace App\Terminal;
use MWGuerra\WebTerminalStream\Themes\TerminalTheme;
final class BrandTheme extends TerminalTheme
{
protected string $fontFamily = 'Berkeley Mono, monospace';
protected string $background = '#0b1021';
protected string $foreground = '#c7d2fe';
protected string $dividerColor = '#312e81';
}
// then: ->theme(BrandTheme::make()) — or BrandTheme::make()->fontSize(16)
What the theme controls
| Fluent method | Applies to | Default |
|---|---|---|
fontFamily(string) |
terminal font | ui-monospace, … |
fontSize(int) |
terminal font size (px) | 13 |
background(string) / foreground(string) |
terminal + surface | #1a1a2e / #e2e8f0 |
cursor(?string) / selectionBackground(?string) |
terminal | none |
palette(array) |
extra ghostty theme keys (ANSI palette) | [] |
dividerWidth(int) |
pane divider line thickness (px) | 1 |
dividerStyle(string) |
divider line style (solid, dashed, …) |
solid |
dividerColor(string) |
pane divider line | slate |
dividerFocusColor(string) |
focused-pane ring + hovered/dragged divider | blue |
On TerminalWorkspace and TerminalGrid, the terminal look is forwarded to every pane that didn't set its own, and the divider styling is emitted as CSS custom properties (--wts-divider-width/-style/-color/-focus, --wts-terminal-bg) on the container — so app-side CSS can override them too. A raw colors array (->theme(['background' => '#000'])) still works for just the ghostty terminal theme.
Scripts are predefined command sequences exposed in a dropdown in the terminal header. Commands are typed into the PTY exactly as if the user had typed them, one per line:
use MWGuerra\WebTerminalStream\Data\Script;
WebTerminalStream::make()
->local()
->scripts([
Script::make('deploy')
->label('Deploy to Production')
->description('Pull, install, migrate, cache')
->commands([
'git pull origin main',
'composer install --no-dev',
'php artisan migrate --force',
'php artisan config:cache',
])
->confirmBeforeRun(),
Script::make('logs')
->label('Tail Laravel Log')
->commands(['tail -f storage/logs/laravel.log']),
])
Plain arrays work too: ['key' => 'logs', 'label' => 'Tail Log', 'commands' => [...], 'confirmBeforeRun' => true].
Options the Stream terminal honors:
| Option | Description |
|---|---|
key |
Unique identifier (required) |
label |
Display name in the dropdown |
description |
Secondary line in the dropdown |
commands |
The command strings sent to the PTY |
confirmBeforeRun() |
First click arms an inline Confirm/Cancel prompt; only Confirm runs the script |
The Script DTO accepts additional options inherited from the parent package (icon, stopOnError, elevated, requiredCommands, willDisconnect, beforeMessage, disconnectMessage); they are carried through but not currently rendered or enforced by the Stream UI — since commands are raw PTY input, there is no per-command exit-code tracking. The confirmation gate is UX, not a security control: a connected user already has a full shell.
A resolved connection config can hold an SSH password or a private key. Since 1.1.0 it never becomes Livewire component state: ResolvesTerminalProperties hands it to the ConnectionVault, and the components keep only an opaque 64-char handle (connectionRef).
Why this changed. Before 1.1.0 the config sat in a
#[Locked] public array $connectionConfig.#[Locked]prevents the client from writing a property back — it does not keep it off the wire. Livewire serializes every public property into thewire:snapshotattribute it renders, so the SSH host, username and private key were present in the page HTML of every terminal, dashboard and workspace (including dashboard sources nobody had opened). See CHANGELOG.
The vault stores the config encrypted in the cache, under the SHA-256 of the handle, bound to the identity that created it — a handle lifted from one user's page resolves to nothing for anyone else. The window slides on each read (stream.connection_ttl, default 2h idle), and once it lapses the terminal reports that it needs a reload rather than connecting to anything else.
Nothing changes in your code: ->ssh(...), ->local() and the standalone @livewire('web-terminal-stream', ['connectionConfig' => [...]]) form all still work. Only reads of the old property have to move:
// Before After
$component->connectionConfig; // $component->connectionConfig()
$props['connectionConfig']; // app(ConnectionVault::class)->get($props['connectionRef'])
If your app only uses the schema components, also close the client-supplied path on the token route:
// config/web-terminal-stream.php
'security' => [
'allow_client_supplied_connections' => false,
],
That endpoint accepts a whole connection config from the caller (the documented standalone flow). With an empty ssh_allowed_hosts it lets anyone past the Gate use the app server as an SSH pivot, so turn it off when nothing needs it.
Define a useStreamTerminal Gate to control who may open a terminal session. It is checked every time a WebSocket token is requested:
use Illuminate\Support\Facades\Gate;
Gate::define('useStreamTerminal', fn ($user) => $user->isAdmin());
No Gate defined = allowed (protect your pages regardless).
Connection lifecycle events (connect/disconnect) are recorded to the terminal_stream_logs table. There is no command-level logging — the stream is a raw byte pipe with no command boundaries.
WebTerminalStream::make()
->local()
->log(
enabled: true,
connections: true,
identifier: 'admin-console', // shows up in the Logs resource
metadata: ['server' => 'web-01'], // merged into every entry
)
// Every parameter accepts a Closure for deferred evaluation
->log(enabled: fn () => auth()->user()->isAuditable())
// Metadata alone
->logMetadata(['environment' => app()->environment()])
Per-terminal values override the logging.* config defaults. Set logging.terminals to a list of identifiers to log only specific terminals.
StreamTerminal dispatches standard Laravel events you can listen to:
use MWGuerra\WebTerminalStream\Events\TerminalConnectedEvent;
use MWGuerra\WebTerminalStream\Events\TerminalDisconnectedEvent;
Event::listen(TerminalConnectedEvent::class, function (TerminalConnectedEvent $event) {
// $event->sessionId, ->connectionType, ->host, ->port, ->sshUsername,
// ->userId, ->terminalIdentifier, ->ipAddress, ->metadata
});
MWGuerra\WebTerminalStream\Listeners\TerminalLogListener is an opt-in event subscriber that writes the same log entries — don't register it if you keep the built-in direct logging enabled, or you'll get duplicates.
The Filament Terminal Logs resource lists entries with filters (event type, connection type, user, terminal, date range), a detail view, and stats widgets:

Query the model directly with its scopes:
use MWGuerra\WebTerminalStream\Models\TerminalLog;
TerminalLog::forSession($sessionId)->get();
TerminalLog::forTerminal('admin-console')->recent(48)->get();
TerminalLog::forUser($userId)->connections()->get();
php artisan terminal-stream:logs:cleanup # uses logging.retention_days (default 90)
php artisan terminal-stream:logs:cleanup --days=30
php artisan terminal-stream:logs:cleanup --dry-run # count only, delete nothing
Schedule it:
Schedule::command('terminal-stream:logs:cleanup')->daily();
// config/web-terminal-stream.php
'logging' => [
'tenant_column' => 'tenant_id',
'tenant_resolver' => fn () => auth()->user()?->tenant_id, // or an invokable class name
],
Install the tenant-aware migration with php artisan terminal-stream:install --with-tenant, then filter with TerminalLog::forTenant($tenantId).
The terminal shows but never connects
php artisan terminal-stream:serve running — in the same app/env as the web request? The server decrypts tokens with APP_KEY and reads the connection config from the app cache; a different .env or cache store breaks both.ws:///wss:// connect will log [StreamTerminal] errors.stream.signed_url_ttl) — a stale tab needs a reload.StreamWeb not loaded in the console / a 404 on …/web-terminal-stream.js
php artisan filament:assets so the browser loads the current file — a stale published asset 404s and the terminal never boots.Address already in use when starting the server
mwguerra/web-terminal server) is on port 8090. Run this package on its own port: php artisan terminal-stream:serve --port=8091 and set WEB_TERMINAL_STREAM_RATCHET_PORT=8091.Mixed-content / immediate disconnect on HTTPS sites
ws:// from https:// pages. Configure WSS (reverse proxy or WEB_TERMINAL_STREAM_SSL_CERT/_SSL_KEY) and set WEB_TERMINAL_STREAM_WEBSOCKET_URL to the wss:// endpoint.Connection drops behind a reverse proxy
Upgrade/Connection) and has a long read timeout (proxy_read_timeout 3600s; in nginx) — idle terminals otherwise get cut.SSH login fails
terminal-stream:serve output. Key content must be the full PEM/OpenSSH private key (not a path). Encrypted keys need the passphrase parameter.Orphaned shell processes after a server crash
storage/web-terminal-stream/pty-sessions.json and kills stale ones (older than stream.max_session_lifetime) on startup and every 60 seconds.Three layers, from fast to full-stack:
# Unit (Pest on Orchestra Testbench — no external services)
composer test
composer test:parallel
composer test:coverage
# Integration (real SSH + PTY against local Docker containers)
composer test:integration # boots tests/docker sshd, runs tests/Integration
composer test:integration:linux # Linux-only PTY resize tests inside the php container
composer test:integration:down # tear the containers down
# End-to-end (Playwright against a dedicated Laravel 13 + Filament 5 app)
npm run test:e2e # scaffolds .e2e-app (gitignored), boots app +
# WebSocket server + sshd container, runs tests/e2e
composer analyse # PHPStan
composer format # Laravel Pint
Integration tests skip automatically when Docker isn't running (and hard-fail on CI). The e2e app is scaffolded by the committed scripts/e2e/setup.sh and connects its terminals only to the throwaway SSH container — never to a shell on your machine. All commands typed by tests are readonly (echo, pwd, stty size, …).
See CONTRIBUTING.md. Found a bug? Open an issue on GitHub.
Open-sourced software licensed under the MIT License.
Marcelo W. Guerra — mwguerra.com
How can I help you explore Laravel packages today?