Contact salesFree trial
Blog

Crafting Hybrid PHP-Go CLIs with Symfony Console

CLIGoPHPsymfony
01 February 2025
Antonis Kalipetis
Antonis Kalipetis
Cloud Engineer, Special Projects
Share

For a quick read-through of the main takeaways, keep scrolling for our distilled write-up. We utilized ChatGPT to enhance the grammar and syntax.

Introduction

When you’ve been running a developer-oriented platform for over a decade, your tooling tends to accumulate some baggage. We found ourselves with a command-line interface (CLI) fully implemented in PHP—a natural choice at the time, given our strong ties to the PHP and Drupal ecosystems. However, as our audience and supported languages expanded, we realized we needed to rethink how our CLI was built and distributed.

  • We wanted a single binary that anyone could install—no extra language dependencies.
  • We wanted to keep the entire command set (and user experience) from our legacy PHP-based CLI.
  • We wanted better performance and easier cross-platform support, which led us to Go.

The result? A hybrid CLI that wraps our existing PHP CLI logic inside a new Go-based binary, brought together with Symfony Console for consistent command routing, autocomplete, and shortcuts. Below is our story of how we got there, why we made certain decisions, and what we learned along the way.

Why We Needed a Hybrid CLI

Originally, our CLI was written in PHP, perfectly suitable for our primary user base at the time. As we grew, so did our users’ tech stacks: Node.js, Python, Go, Ruby, Symfony, and more. We needed to deliver a CLI experience to teams who might not have PHP installed—nor did they want to install it just to run one tool.

Key goals that pushed us to reconsider our CLI approach:

  1. Backward compatibility. We couldn’t just drop our existing PHP CLI and break everyone’s workflows.
  2. Performance without sacrificing functionality. We wanted a faster startup time and a robust feature set.
  3. No more forced PHP dependency. Installing the CLI should “just work” on any operating system or architecture.

Choosing Go for the New CLI

Go is widely used in the developer tooling ecosystem (e.g., Docker, Terraform and countless other command-line tools). It compiles to a single static binary that runs quickly and is easy to distribute for Linux, macOS, and Windows. These were our primary motivators:

  1. Single binary distribution. No extra dependencies, straightforward installation.
  2. Cross-platform compilation. Compile once for each target—no complicated build chains or external tools required.
  3. Speed. Go’s startup time and concurrency model help the CLI feel more responsive.

How to Run PHP from Inside Go (Yes, Really)

We still needed all our existing PHP code—thousands of lines’ worth of logic—available to users. But how do we run that inside a Go binary without requiring separate PHP on the user’s machine?

  1. Embed a minimal PHP binary. We built a stripped-down version of PHP with only the extensions our CLI needed.
  2. Bundle that inside the Go binary. When the user executes a “legacy” command, Go calls into that embedded PHP interpreter.
  3. Route “legacy” vs. “new” commands. If it’s a legacy command, spin up the embedded PHP. If it’s one of our newer features, stick to Go.

This setup meant we didn’t have to rewrite all existing commands immediately. By embedding PHP, everything kept working, but we could still add fresh functionality in Go without holding up the entire release.

Enter Symfony Console (in Go!)

We relied on Symfony Console in our original PHP-based CLI for arguments, shortcuts, and autocompletion. We wanted to keep that familiar user experience—shortcuts like p:init for project:init, or well-structured help texts, etc.

It turns out the official Symfony CLI is written in Go, and it includes a Go-based Symfony Console component. By swapping our prior Go library (Cobra) with this Symfony Console for Go, we gained:

  • Consistent command routing. Load all commands, parse them in the same place—then decide whether Go or PHP will handle them.
  • Autocompletion. Symfony Console handles completion generation, so we just expose the full command list.
  • Familiar shortcuts. Achieve the same user-friendly experience we had in the PHP CLI.

Rolling Out the New CLI

  1. Legacy CLI remains primary at first. We continued maintaining the PHP CLI to ensure nothing broke for existing users.
  2. New commands debut in Go. We added fresh, specialized commands (e.g., project:init, validate) in the new Go code.
  3. Incremental migration. Over time, we will be moving older PHP commands into the Go codebase. All the while, the CLI is still one single binary.

Of course, maintaining two codebases (the embedded PHP for old commands and new Go code) takes extra work. But it also gives us the freedom to move at our own pace, ensuring each feature is thoroughly tested without forcing a massive, big-bang rewrite.

Testing, Testing, Testing

We never wanted to break developers’ production workflows:

  • Integration tests ensure each command in the new CLI behaves exactly as in the old one (same input, same output).
  • Unit tests for our Go commands catch any new functionality regressions.
  • Mock-based end-to-end tests spin up mock servers to validate real-world flows, from initial call to final output.

What’s Next?

We’re steadily migrating more commands to Go, especially those requiring authentication. Once that’s complete, we can fully unify how credentials and tokens are managed across the new binary—potentially improving SSH key handling and secure storage integration. We’re also exploring ways to let third-party developers extend the CLI, now that the codebase is more modular.

Conclusion

Rewriting a mature CLI is rarely simple—especially when thousands of developers rely on it daily for production workflows. By adopting a hybrid architecture, we kept existing PHP-based functionality, drastically improved distribution (single binary, no language dependencies), and laid a foundation for future expansions in Go.

Key takeaways:

  1. Hybrid approaches can buy you time. There’s no need to rewrite thousands of lines of legacy code overnight.
  2. Embedding interpreters is possible. If you need older languages or frameworks, shipping small, curated builds can work surprisingly well.
  3. Testing is king. In a hybrid environment, integration and end-to-end tests are crucial for avoiding regressions.

We hope this journey might inspire you if you’re facing a similar legacy-versus-future dilemma. Whether you’re exploring new languages, new distribution models, or ways to unify your developer experience, a hybrid CLI might be exactly what your team needs.

Discord
© 2025 Platform.sh. All rights reserved.