recca0120/terminal
Run Artisan and shell commands directly in the browser with this Laravel web terminal package. It provides a configurable terminal UI, predefined command buttons, and real-time output, making it handy for local debugging, demos, or admin panels without SSH access.
1. Install (dev-only first).
composer require recca0120/terminal --dev
Install as a dev dependency initially. If you later need it on a staging/admin host, promote it to require intentionally rather than by accident.
2. Publish the config.
php artisan vendor:publish --tag=terminal
# or, if the tag doesn't exist in your version:
php artisan vendor:publish --provider="Recca0120\Terminal\TerminalServiceProvider"
Open config/terminal.php before you visit the route. This single file is your security surface: enabled, middleware/guards, route prefix, and any command allow/deny lists. Key names have shifted between releases — treat the published file as the source of truth for your installed version, not blog posts.
3. Lock it down before the first request.
app()->environment(['local']) (or a dedicated staging) unless you have a real auth story.auth (and ideally a policy/gate) plus a restrictive middleware group.4. Visit the route. Default is usually /terminal (configurable via the route prefix). If you get a 404, run php artisan route:list | grep -i terminal — the service provider may not be auto-discovered, or the route is disabled by config.
5. First use case — verify the runtime, not just the UI. From the browser terminal run:
php artisan about
php artisan route:list --except-vendor
That confirms which PHP binary, which .env, and which working directory the spawned process actually sees. If those three match your expectations, everything else is downhill.
Where to look first when something breaks:
config/terminal.php → the service provider (route + middleware registration) → storage/logs/laravel.log → your web server's error log. The browser terminal's own error output is often swallowed by the streaming layer, so the server log is the reliable one.
Environment-gated enablement (the default posture).
// config/terminal.php
'enabled' => env('TERMINAL_ENABLED', false),
'middleware' => ['web', 'auth', 'can:use-terminal'],
// AppServiceProvider::boot()
Gate::define('use-terminal', fn ($user) => $user->isAdmin()
&& app()->environment(['local', 'staging']));
The env var makes it trivially switchable per-host without a deploy, and the Gate means a compromised session on a non-admin account gets nothing.
IP allowlist middleware for staging/admin boxes.
class TerminalIpGuard
{
public function handle($request, Closure $next)
{
abort_unless(in_array($request->ip(), config('terminal.allowed_ips', []), true), 403);
return $next($request);
}
}
Prepend it to the package middleware array. This is the cheapest meaningful defence when the terminal lives somewhere other than localhost.
Command allowlisting for team-facing installs. If you hand a terminal to non-senior devs or support staff, restrict to a known-safe set (php artisan migrate:status, cache:clear, queue:restart, route:list). Check the config for whatever the allowlist key is in your version; if it only supports a blacklist, invert the logic by wrapping the route in a middleware that inspects the posted command. Never rely on a blacklist — php -r, composer, and shell redirection all bypass naive string matching.
Long-running processes: detach, don't block.
queue:work, horizon, npm run dev, and artisan serve will tie up the request and eventually hit max_execution_time / FPM request_terminate_timeout. Run them detached and tail the log in the terminal instead:
nohup php artisan queue:work --tries=3 > storage/logs/queue.log 2>&1 &
tail -f storage/logs/queue.log
Use the terminal for observing long-running work, not for hosting it.
Audit logging via route middleware. Wrap the terminal route with a middleware that records user id, IP, and timestamp on entry (the package won't do this for you in most versions):
Log::channel('terminal')->info('terminal.session', [
'user' => auth()->id(), 'ip' => $request->ip(), 'ua' => $request->userAgent(),
]);
Pair it with a retention policy on that channel — shell history is sensitive.
Custom Artisan commands as terminal "shortcuts". The best use of the terminal is running your own domain commands (php artisan app:resync-orders --since=...) rather than raw shell. Write the dangerous logic in a proper command with validation and confirm() guards, then let the terminal be the trigger. You get testability and logging for free.
Admin panel integration. Link to the terminal from Filament/Nova, or embed it in an iframe behind the same auth. Keep it on a separate route group with its own middleware so panel-level rate limiting doesn't interfere with streaming output.
Composer and asset work on remote boxes. The genuinely high-value day-to-day case: composer install --no-dev, php artisan migrate --force, php artisan config:clear on a host where SSH is awkward. Always run config:cache from SSH (or a proper deploy pipeline) afterwards — see the gotchas.
The spawned process is not your shell. It inherits the PHP-FPM user's environment, not your login shell. Expect missing PATH entries (~/.local/bin, nvm, asdf), no COMPOSER_HOME, and a different HOME. Symptom: "command not found" for something that works over SSH. Fix by using absolute paths or exporting in the command itself: PATH=$PATH:/usr/local/bin composer install.
No TTY means interactive prompts hang. Anything that asks a question (artisan migrate without --force in production, migrate:fresh, composer scripts, npm init) will sit waiting until the request times out, with no visible prompt. Always pass --no-interaction / -n and --force where appropriate, or run the interactive parts over real SSH.
Timeouts stack. The browser terminal can die from any of: max_execution_time, FPM request_terminate_timeout, nginx fastcgi_read_timeout / proxy_read_timeout, and any reverse-proxy idle timeout. If output stops mid-stream but the command clearly ran, it's almost always nginx buffering or gzip, not the package. Disable buffering and compression for the terminal route:
gzip off;
proxy_buffering off; # or fastcgi_buffering off;
Session locking blocks concurrency. With the default file or database session driver, Laravel locks the session for the duration of a request. A long-running terminal command can lock you out of the rest of the app in the same browser. Either keep commands short or give the terminal route a separate session/cookie configuration. This one bites people in production and is genuinely confusing to diagnose.
Running as www-data with www-data's permissions. Files created by the terminal (storage/, cache, generated migrations) are owned by the web user. Then SSH deploys fail with permission errors. Prefer commands that write only where the web user should write, and be ready to chown after a terminal-created file.
Interactive TUIs (vim, top, htop, tinker REPL) are unreliable. The package proxies a stream, not a PTY, so full-screen applications render garbage or freeze. Use it for line-oriented commands and --format output.
ANSI escapes and control characters. Commands emitting colour/progress bars (composer, npm) can produce unreadable output if the frontend doesn't emulate a terminal properly for your version. Add --no-ansi / --no-progress when output looks mangled.
config:cache breaks env-driven toggles. Once config is cached, TERMINAL_ENABLED changes in .env do nothing until php artisan config:clear. If the terminal won't disappear after you disabled it, that's why — check bootstrap/cache/config.php.
cd doesn't persist. Each command runs in a fresh process rooted at base_path(). Chain commands or use absolute paths rather than expecting directory state.
Orphaned processes have no kill switch. Close the browser tab and a detached queue:work keeps running. Prefer Supervisor/systemd for anything long-lived, and know how to ps aux | grep artisan and kill when a stray worker starts eating CPU.
Audit and cleanup. By default, executed commands may only appear in the web server access log — not your application logs. If the terminal is available to more than one person, add the audit middleware above and decide retention before an incident, not after.
Octane and long-lived workers. If the host app runs under Octane/Swoole, streaming endpoints can behave differently or leak per-request state. Test the terminal under the same runtime as production before declaring it "fine".
Treat any exposed instance as remote code execution. The package is, definitionally, an RCE endpoint for anyone who reaches it. Minimum bar for anything non-local: real authentication, a Gate/policy, IP restriction, HTTPS, and an audit trail. When in doubt, keep it in require-dev and never ship the route to production.
How can I help you explore Laravel packages today?