Need a place for your Laravel app in the cloud? Fly it with Fly.io, itβll be up and running in minutes!
The CLI and web browser don’t really talk to each other. Let’s force some communication and make it π wyrd π.
We’re going to let users create an account (“register”) in our app, using the CLI and their web browser.
CLI as User Interface
The flyctl
(aka fly
) command is the main UI for Fly.io. There’s nothing special about how it works tho - to create servers, deployments, and everything else, it’s “just” making calls to the Fly API. This requires an API key.
The fly
command can get that API key for you automatically. Getting the API token happens on registration or login (fly auth signup|login
). The command kicks you out to the browser. After you sign up and return to your terminal, flyctl
knows you’ve authenticated and already has a new API key.
How does flyctl
know you’ve done that, and how does it get the API key?
Let’s see how (and do it ourself).
The Magic
There’s no magic, not even a hint of π§ββοΈ witchcraft.
The signup
command gets a session ID, and performs an extremely polite-but-boring polling of the API (using that session ID) to ask if the API could please inform us if the user has taken the time out of their busy schedule to perhaps finish the registering they requested.
What we’re doing here will resemble what flyctl
does, but we’re going to do it in Laravel.
The process is this:
A register
command will:
- Ask for a new CLI session (we get a token back)
- Kick the user out to the browser to register, passing the session ID
- Poll the API with the session ID to ask if the user finished registering
Our application needs:
- API endpoints to handle creating a CLI session and checking on its status
- Adjustments to the browser-based registration flow to capture the CLI session ID and associate it with a user who registered
Project Setup
We’ll create a new Laravel project, using Breeze to scaffold authentication. You can β‘οΈ view the repository β¬ οΈ for any details I hand-wave over.
Here’s a quick set of commands to scaffold everything out:
# Create the web app that a user will interact with in the browser
composer create-project laravel/laravel browser-cli
cd browser-cli
composer require laravel/breeze --dev
php artisan breeze:install
npm i && npm run build
# Create a model, migration, and resource controller for
# CLI session handling
php artisan make:model --migration --controller --resource CliSession
# Create a CLI command that we'll use, but in reality would likely
# be a separate code base that a user gets installed to their machine
php artisan make:command --command register RegisterCommand
The model and migration are extremely vanilla. We get a UUID as a CLI session ID, and can associate it with a user.
Let’s dive into the details, starting with the register
CLI command, since it’s the “top-level” thing we’ll be doing.
Register Command
I created an artisan register
command within the same code base as the API. This is just easier for demonstration. In reality, you’d likely create a separate CLI-only app for users to install (and it would talk to your API, which is probably a whole separate Laravel code base).
The register
command is going to get a new CLI session, kick the user to the browser, and then wait for registration to happen. It polls the API, asking if the user registered. When registration happens, the CLI will get a valid API token for the user related to the CLI session ID.
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
class RegisterCommand extends Command
{
protected $signature = 'register';
protected $description = 'Kick off user registration';
public function handle()
{
// We're using the same code base for the API, so lets
// just get the URL for cli-session stuff
$baseUrl = url('/api/cli-session');
// Get a new CLI session
$cliSessionResponse = Http::post($baseUrl, [
'name' => gethostname(),
]);
if (! $cliSessionResponse->successful()) {
$this->error("Could not start registration session");
return Command::FAILURE;
}
$cliSession = $cliSessionResponse->json();
$stop = now()->addMinutes(15);
// TODO: Using "open" is different per OS
Process::run("open ".$cliSession['url']);
// Poll API for session status every few seconds
$apiToken = null;
while(now()->lte($stop)) {
// check session status to see if user
// has finished the registration process
$response = Http::get($baseUrl.'/'.$cliSession['id']);
// non-20x response is an unexpected error
if (! $response->successful()) {
$this->error('could not register');
return Command::FAILURE;
}
// 200 response means user registered
if ($response->ok()) {
// response includes an API token
$apiToken = $response->json('api_token');
$this->info('Success! Retrieved API token: ' . $apiToken);
break;
}
// Else I chose to assume we got an HTTP 202,
// meaning "keep trying every few seconds"
sleep(2);
}
// TODO: Success! Store $apiToken somewhere for future use
// e.g. ~/.<my-app>/.config.yml π¦
$this->info('success!');
return Command::SUCCESS;
}
}
Just as advertised, we’re starting a session and then polling the API for its status (for up to 15 minutes).
Once the user registers, we get an API token back that we can use to perform future actions as that user.
One thing I skipped over is how we use the open
command to open the web browser. How you do that changes per OS. See examples of how core Laravel does this across OSes here.
API Routes
We have /api/cli-session
routes to handle! Within routes/api.php
, we can register the resource controller. The interesting part is the CliSessionController
itself:
<?php
namespace App\Http\Controllers;
use App\Models\CliSession;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class CliSessionController extends Controller
{
// Create a CLI session when requested by
// the register command
public function store(Request $request)
{
$request->validate([
'name' => 'required',
]);
$cliSession = CliSession::create([
'name' => $request->name,
'uuid' => Str::uuid()->toString(),
]);
return response()->json([
'id' => $cliSession->uuid,
'url' => route('register', [
'cli_session' => $cliSession->uuid
]),
]);
}
// Allow the register command to poll
// for session status
public function show(string $id)
{
$cliSession = CliSession::with('user')
->where('uuid', $id)
->firstOrFail();
// Session exists but no user yet
if (! $cliSession->user) {
return response('', 202);
}
/**
* User is associated with session
* (successful login or register).
* Give them a new, usable API token
*/
// Ensure no one can re-use this session
$cliSession->delete();
// TODO: Generate a for-real api token
// perhaps via Laravel Sanctum π¦
return [
'api_token' => Str::random(32),
];
}
}
It only has two jobs:
- Let the
register
command start a session - Let the
register
command poll that session to see if the user finished registering
Creating a session just involves making a new cli_sessions
table record with a new UUID. That UUID is passed back and becomes the session ID the register
command uses to check on the session status.
The register
command then polls the show
method. When the user finishes registering, the it returns a valid API token for the App\Models\User
associated with the CLI session. That’s the end of the process!
Browser Registration
We need to tweak the registration process so it becomes aware of the CLI session.
When a user registers, we need to associate the CLI session with the newly created user.
The API route checks for an associated user - if there is one, it assumes the user registered. It can then return an API key next time it is polled!
What those tweaks looks like depends if you’ve hand-rolled authentication, or used Breeze/Jetstream/Whatever. Here’s what it looks like for Breeze.
What we do is:
- Include a
cli_session
hidden<input />
in the registration form with the UUID of the session (if present) - Adjust the registration POST request to find and update the CLI Session record to be associated with the just-created user
Relevant snippets from the adjusted Breeze-generated app/Http/Controllers/Auth/RegisteredUserController.php
/**
* Display the registration view.
*/
public function create(): View
{
// 1οΈβ£ Add the CLI session UUID to the register view
return view('auth.register', [
'cli_session' => request('cli_session')
]);
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([...]);
$user = User::create([...]);
event(new Registered($user));
Auth::login($user);
// 2οΈβ£ Associate user with cli session, if present
if ($request->cli_session) {
$cliSession = CliSession::where('uuid', $request->cli_session)
->firstOrFail();
$cliSession->user_id = $user->getKey();
$cliSession->save();
// TODO: Create this route with message letting
// user know to head to back to CLI
return redirect('/cli-session-success');
}
return redirect(RouteServiceProvider::HOME);
}
Two hand-wavy π things:
- You’ll need to update the
auth.register
view to add a hidden input and store thecli_session
value - I didn’t bother creating a “success!” view that tells the user to head back to their terminal
That’s It
Overall, it’s not too bad - we didn’t need to overhaul our registration process! We just hooked into it a bit in a way that allowed for the addition of a CLI session.
The register
command did most of the work, and all that entailed was asking for a UUID and polling an endpoint.
Other Wacky Ideas
The main blocker for CLI-browser communication is the communication itself. How could the browser “push” data to a terminal process?
In our case, we didn’t “push” data anywhere. The terminal command was polite - it asked for the CLI session, and then asked for the status of the CLI session.
Here’s 2 zany ideas, both of which sort of suck for PHP but might fit in with other languages with stronger concurrency/async.
Warning: Strong “draw the rest of the owl” vibes here.
- Web sockets - Have your command and the browser connect to a web socket server, and communicate that way
- Web server - Have your command start a web server, listening at
localhost:<some-port>
, then have your web app redirect tohttp://localhosts:<some-port>/success
(or whatever) when registration is complete