Behind the Terminal
When I set out to rebuild my personal website in early 2024, I wanted something with a layer of interaction. Clicking through links to learn about someone feels tedious. What if there was a bit of friction that made it more engaging?
The idea was to make the site feel like a terminal. Type about and you get a bio. Type help and you see what's available. It might be counterproductive for some visitors, but if someone is curious enough to look me up, hopefully it makes the experience a bit fun rather than just another portfolio to skim through.
I figured my audience would mostly be software engineers, and we're used to terminals. For those who'd rather just click links, there's a table of contents.
The site has two main areas: a terminal for user input and an HTML section with formatted content. Each command renders both: a plain text output for the terminal, and a rich view with images, links, and styling beside it. Command history works with arrow keys, there's dark mode, and aliases let you type bio instead of about.
Under the hood, it's a Laravel app using Vue and Inertia for the frontend. But the interesting part is how commands are defined and resolved.
The Command System
Each command is a PHP class decorated with attributes. Here's what the about command looks like:
#[ConsoleCommand(
name: 'about',
description: 'Show bio',
aliases: ['bio', 'whoami'],
)]
class AboutCommand extends Command
{
public function handle(): Response
{
return $this->render('About', [
'exitCode' => 0,
'output' => "Hello, I'm Gustavo Karkow...",
// ...
]);
}
}
The #[ConsoleCommand] attribute is where the magic happens. It defines:
name: The primary command namedescription: A brief description that gets displayed when you typehelpaliases: Alternative names for the same commandhelp: Whether to list it in help outputweb: Whether it's accessible via direct URLcli: Whether it's accessible via terminal
This keeps everything about a command in one place. No separate route files, no config arrays scattered around. Just the class and its metadata.
Auto-Discovery
I didn't want to manually register each command. The solution was auto-discovery using Symfony's Finder and PHP's Reflection API:
class CommandRegistry
{
private static function discover(): array
{
return collect(Finder::create()->files()->name('*Command.php')->in(__DIR__))
->map(fn ($file) => 'App\\Commands\\'.$file->getBasename('.php'))
->filter(fn ($class) => class_exists($class))
->mapWithKeys(function ($class) {
$reflection = new ReflectionClass($class);
$attributes = $reflection->getAttributes(ConsoleCommand::class);
if (empty($attributes)) {
return [];
}
$attribute = $attributes[0]->newInstance();
if ($attribute->disabled) {
return [];
}
return [
$attribute->binding => [
'attribute' => $attribute,
'class' => $class,
],
];
})
->filter()
->all();
}
}
It finds all *Command.php files in the commands directory, uses reflection to check if they have the #[ConsoleCommand] attribute, and builds a registry of command bindings mapped to their classes. Skip disabled commands, and you're done.
Drop a new command file, add the attribute, and it's live. No registration step.
To not have CommandRegistry discover the commands for every request, I added php artisan command:cache to cache it to a file—similar to how Laravel does it with route:cache. Methods like cli(), web(), and help() filter commands by their attribute flags, making it easy to get subsets for different contexts.
Command Resolution
When you type something in the terminal and hit enter, a middleware intercepts the request:
use Symfony\Component\Console\Input\StringInput;
class ResolveCommand
{
public function handle(Request $request, Closure $next): Response
{
$prompt = $request->string('command');
$input = new StringInput($prompt);
$binding = CommandRegistry::resolve((string) $prompt);
if ($binding === null) {
throw new CommandNotFound;
}
$command = app()->makeWith(".$binding", [$input]);
Context::add('command', $command);
return $next($request);
}
}
A few things are happening here:
-
StringInput: Symfony Console's input parser handles the raw command string. This means commands can accept arguments like
articles 3orexp -c digicast. -
Pattern matching: The registry tests the input against regex patterns generated from each command's name and aliases.
-
Service container: Commands are resolved from the container using a dot-prefixed binding (
.about,.help). This lets Laravel inject dependencies if needed. -
Context: The resolved command gets stashed in Laravel's Context so the route handler can access it.
The route itself just calls handle() on whatever command was resolved:
Route::post('/', function () {
return Context::get('command')->handle();
})->middleware(ResolveCommand::class);
Tech Stack
- Laravel 12 + Inertia v2 + Vue 3 for the stack
- Symfony Console components for input parsing
- Tailwind CSS v4 for styling
- Laravel Folio for the articles (they're just Markdown files)
Closing Thoughts
Building this was fun. The command system ended up being cleaner than I expected. PHP attributes made it possible to keep everything declarative, and auto-discovery removed the tedium of manual registration. If I ever want to add a new command, it's just one file.
There are a few easter eggs too: try typing rm -rf / or matrix, for example.
Is this the best way to handle a terminal-like interaction on the web? Probably not. But for a personal site that's meant to show personality, it works.