I’ve been writing a PHP-syntax JIT runtime built with Go somewhere since June 2026. So for about 4 months.
Why?
I chose PHP because in the landscape of interpreted runtimes, it’s the language I know best. I’ve been writing go code for over a decade now, but I keep a certain fondness and familiarity with the language, and a certain distaste and technical aversion for node_modules. I wrote it because PHP itself is 25 years of decisions on the language, and since I switched to Go over a decade ago, I have little wish to run the official runtime.
I’ve worked on PHP in high-traffic situations so performance considerations have always been a part of that time when I was writing PHP code for money. These days, I’m exploring my options, and writing a PHP runtime was a viable one.
The project is openly available at github.com/titpetric/phpscript, and the readme and docs should serve as a good entrypoint into the runtime.
The project quite a bit different than a full PHP runtime, but several of the runtime features are implemented and tested as compatible. The main deviation is that phpscript does not implement the full PHP standard library. It leans into Go standard library bindings but still manages a compatibility layer that is able to run valid PHP code. I’d recommend it for:
- Virtual hosting from a single container
- High performance API and websites
- Third party integrations with near-native performance (e.g. plugins)
The rest of the article is meant to be illustrative about composition in the PHP runtime, Go runtime, what composition looks like in phpscript. It’s mainly focused on the runtime and the challenges when implementing one in the Go programming language.
Runtimes and composition
A Go application is composed together using packages, local or remote, pulled with go get. The package manager allows embedding PHP source trees in an embed.FS with go:embed.
A PHP application may use composer, a package manager that unpacks a package into a vendor/ folder and creates an autoload.php with class mappings. It’s also able to automatically run code from the package being included. The hook is important since it allows dependency inversion - the package adds itself to some kind of plugin registry or virtual host folder structure, depending on the how you want composition to look.
The ask is simple enough: how would you make http.Handler modular, without invoking compile time safety? And hopefully, without much overhead. With phpscript, I’m interested in delivering virtual hosts through a package system of some-kind, as well as provide a good integration path into Go apps.
The approach I took opens up new application composition methods for Go applications.
Some informed thought justified the LLM use cases:
- implicitness reduces syntax, reduces codebase verbosity, reduces tokens usage
- the runtime doesn’t require rebuilds of their binaries and docker images
- no CGO, no cross build toolchains, i don’t want
cchere - mechanical checks with linters, my own splint
- automate all the things, and most things I need automated are my own process
In the age of LLMs, short feedback loops are optimized to form applications where you don’t have to constantly run and rerun applications, and possibly their docker build containers. A latent benefit to implementing some compatibility is the models ability to write PHP code. It is not exactly trained to favor standard library only code, mostly because such thing hasn’t been the norm ever since PHP got a package manager. It’s unfortunately well trained on the bad parts as well. I feel like I haven’t had to use chr and ord once since I started writing Go, and I find issue on implementing the bad parts in the name of compatibility.
It opens up this way of developing both the application server, create your own repository layers to best practices and just bind the Go API to PHP use. PHP with custom Go everything.
The frustrations of a PHP runtime
I’ve worked with the official PHP runtimes for something like 20 years. Thankfully in some ways, that part of my life is over. It’s been replaced with Go over a decade ago. The contrast of both runtimes favors one way or the other to approach distributed systems problems.
A programming language, either interpreted or compiled is usually done with quite similar process:
- figure out a syntax definition, to create a
parser/language, - use the parser to hydrate an abstract-syntax-tree or
parser/ast, - route the AST into a runner (
compiler,interpreteror combination of both)
Parse the code, transform or walk the AST and a path to execute. Everything is much of the same. In fact, there are two runtimes in phpscript, that optimize in various ways to keep overhead low, or choose a different execution model to hold together bits and pieces of the code, it’s program counters.
As the syntax tree gets transformed by a compiler into an executable unit of “statement”, the compiler optimizes away things like string concatenation, literal expression, does things like code inlining and other steps that make it go faster. A compiler is the place that does the type checking, and it’s outputs are generated to be correct for the type. You’re not going to find an io.Reader that doesn’t implement Read.
What runs is only the code that survives the compilation step.
The frustrations with PHP have always been about it’s runtime. The single threaded process model, the overheads of all-VM typing, request pool management, a FastCGI service coupled to another web-server process. I’ve learned to write code that didn’t stress the runtime, or the person writing it.
Interpretation
Other examples of interpreted runtimes are products like Taskfiles, GitHub Actions. These interpreters rely on the existing yaml extensions they use. A schema is managed and constructed that enables variable interpolation, loops, conditions and scripted execution. It’s really simple to write an interpreter, see:
type Unit struct {
Message string
For int
}
func interpret(messages []Unit) {
for _, message := range messages {
for range message.For {
fmt.Println(message.Message)
}
}
}
This is a simple interpreted problem, even too simple, but it shows exactly how these tools work. There is a known data schema, and a known resolution to the parsing and execution of that program. The programs can change, but the interpreter only allows it’s known types.
Template engines like html/template rely on their own parser, while template engines like VueJS rely on a different data model - the DOM, a Document Object Model where all the HTML structure is traversable. It has parents, children, attributes and data to use. It still leaves something to be desired on the basis of the data model and it’s restrictions. For example:
<div v-for="item in items">{item}</div>
<div v-for="item in items" v-if="item.length > 0">{item}</div>
<div v-for="item in items" v-if="items.length > 0">{item}</div>
The example showcases evaluation order - is if evaluated before for, or does it evaluate inside the for loop? Is attribute order also evaluation order? Somebody writing it had to choose. Since maps generally don’t keep order, the question is, did I just get Attr as a map[string]any?
The only logical answer to that is to write a programming language evaluation. With block scopes and statement order, we have the richer abstraction of program flow control than test tooling and template engines are able to provide.
A template engine isn’t enough to provide a whole program.
Programming languages
Most programming languages, compiled or interpreted, don’t differ that wildly from each-other. They implement a type system, exert some form of memory control practices, and provide a parser, an interpreter, a compiler, a way to run the program.
Any language (within the same LISP language family to not cast too wide of a net) usually supports the same things ; program control flow, iteration, conditions, expressions. For reference, there’s the Lua 5.3 manual: 2.4 metatables and metamethods that lists all these basic building blocks of a full runtime.
My experiment with titpetric/gozero proposes a runtime without these - no control flow, no iteration, no conditions or expressions, no closures - just the native type, value and callback system. Go provides a binding that quickly enables implementing equality over held values in the form of reflect.DeepEqual. The savings come from imperative execution, which omit really 80% of what a runtime does. This makes it tiny, fast and special-use case which can be solved with imperative execution. A certain set of problems get solved more effectively than a general-use approach.
Doing 3*4? Well, now you need an __add function. The first commits of phpscript used expr-lang/expr-go and for a long time, this was the expression engine in phpscript. Using it has been a shortcut early on to avoid implementing expressions in phpscript.
From the perspective of Go, phpscript is a request-lived runtime. The service runtime provides garbage collection, and the request runtime provides fuzzy memory accounting estimates, so requests don’t blow up RAM usage. The PHP language runtime has a high barrier of entry to contribute to the service runtime, and this makes PHP awkward about some things like request routing.
What can Go do better?
Allocations
There are caveats and realities of the go compiler and runtime that I was faced with when writing phpscript. The first one was allocation - specific to the PHP runtime, each request gets a runtime counter of how much memory it allocates. If you go over the memory limit - the request does a HTTP 503. Memory limits are tricky in PHP because of it’s pooled single-threaded runtime. Exhaust a limit and you have backpressure. The issue is, memory limits are usually hit by poor code that allocates too much. Really, to sum it up:
- don’t allocate things that cause pressure to the GC
- have a view of how much you do allocate
The first one is solved with benchmarks and optimizations, the latter through some sort of instrumentation. I took a little bit of detour there, from the simple idea of a status page. I created titpetric/oida in Go, an in-memory ring-buffer style telemetry framework, with a built in UI to inspect the collected data. I use it in phpscript to have per-vhost tracing and optimized it somewhat.
Bindings
For virtual machine runtimes, some form of binding system is expected. From the perspective of a program, we can create a map[string]any and assign values like d["*net/http.Request"] = http.Request and get a lot of usable type and binding information from a system. The problem becomes that binaries build the complete standard library. Go programs optimize away a lot of the unused code and don’t provide a way to interface what survives.
I discovered go tool nm which allows you to find the symbols that are compiled into your binary. Those symbols have a program counter number which can be converted to an unsafe.Pointer. The limitation of the go runtime that could be mitigated is to provide a dictionary about compiled-in symbols, and their type information. The problem with an unsafe.Pointer is obviously that you don’t know it holds a func(http.ResponseWriter, *http.Request) or anything else. The unsafe hints where we are with this. The work-around of course is to do a little PGO optimization of your own:
- build binary, whatever it is, collect bindings with
go tool nm, - generate bindings for all the functions already compiled in,
- recompile and use the
map[string]any
This still leaves stuff on the table, but programmatic control of the dictionary can just create a number of fluid bindings, bind whole packages at the traversal of a loop, and executor code in a virtual-machine loop can use this. Which brings me to:
Call and binding overhead
To understand how code is executed via reflection, one only has to look at the function signature for reflect.Value.Call and it’s cousin CallSlice; the difference between the two is the latter fills the variadic arguments. The yaegi Go interpreter runtime exclusively relies on reflection to run what it parses.
The stack of a runtime holding reflect.Value for the stack isn’t something that appeals to myself. Reflection can be avoided with a func([]any) []any binding, type checking at the compiler level. The fastest path is the one that avoids reflection altogether.
var scopedVar any = strings.NewReader(someJSONData) // boxed values in `any`
fn := bindings["json.NewDecoder"].(DecoderFunc) // avoids reflect.ValueOf
reader := scopedVar.(io.Reader) // avoids reflect.ValueOf
decoder := fn(reader) // avoids (reflect.Value).Call
For the purposes of the phpscript interpreter runtime, this means some wrapping of bindings, trying to resolve this at compile time rather than runtime, and the awkward situation of creating all bindings as func([]any) []any wrappers to avoid allocation penalties due to reflection overhead.
The awkwardness of this runtime integration is also visible in standard library decisions like implementing iter.Seq and iter.Seq2. In the example of constructor conventions with New functions, some implement a (T) return, others a (T, error), and there is a third type of constructor function which is best deleted. Returning more than two values involves type translation to the phpscript runtime to a Result type of sorts.
The implementation of phpscript takes a few stances here:
- Errors: the
errorreturn becomes theException, no moreif err != nil - Context:
context.Contextcan be implicit, scriptfn()invokes the Gofunc fn(context.Context)
The abstraction of syntax produces less code and has some benefit in coding-agent authoring workflows because of this. If you ever think about data composition, Go is a very explicit-syntax for language. There are coding patterns which can get you out of error handling the idomatic way, but as you’re fighting with the language to avoid something the language is tailored for, perhaps a higher abstraction is the only way out.
What phpscript does better?
Aside being an overall minimal runtime in regards to what we consider PHP, I’ve deviated a lot from their approach to the standard library. For example the database bindings reference connections by name. A configuration file of the service keeps the credentials, and the phpscript runtime never gets a string value that should be redacted. Go handles connection setup, and the requests get a connection by it’s name.
My implementation decisions around not implementing extends and global decreases the compatibility of what has already been written in PHP. The type of code that phpscript optimizes for is a compatible subset of PHP. My approach matches the idiomatic patterns of go: dependencies get passed along into functions, and encapsulation is the method of composition. Barely anyone writes PHP code like this.
The trade-off I’m making here is between “bad code” which cannot be made either safe or efficient, and the reduction of syntax as an approach to making it both. The PHP runtime decouples from it’s C runtime as much as it can, while phpscript integrates with the Go runtime and can just invoke the go methods available to it, ideally with as little type translation as possible, favoring definitions in Go for performance.
It could be argued that a system of inheritance, or a compile time composition system that resolves the inheritance is interesting and even trivial to implement. It may make sense for Java. I’m a firm believer that composition is the way to good and reliable software, so every class is final and there is no global bag of values. I may implement __call at some point. Who knows, it may already be there.
I’ve made the decision on inheritance a long time ago. One (arguably) the biggest projects I worked on at the time was RTV Slovenia, and one of the core components was the news module, along with a newsadmin module with the back-office. The structure implemented a DAO, otherwise referenced also as the “repository”. Composing two different views of the data by inheritance and overloading has led to one of those situations where frustration mounts. Each file was a few 100KB of code, fragile, shared responsibility and impact when something goes wrong - if you were likely to make a mistake, you were likely to make at least two of them with inheritance.
The decision to remove global was fueled by writing Go applications in a certain way, to test them in a certain way. Writing tests I’ve seen too many serial examples, examples where application state is shared, where that shared test state is a barrier to testing software with real concurrency, to flag a test run with -race, or bump parallelism with the -p flag. I think shared state is unavoidable, but having request-scoped allocation for state shared during the lifetime of the request? Just takes a different way to build things.
There are other things that phpscript is good at at my opinion over PHP that have more to do with the decoupled request lifetime that is PHP. A router in phpscript is created on service level, so requests don’t have to stress about building a route dictionary and matching during every request. PHP was created when GET /phpinfo.php was the norm, and “pretty links” were not a solved problem by the engine.
This blog, this website - I wrote it in phpscript. It is still work in progress (as anything) - but such is software. Evolving. With the one caveat, phpscript isn’t likely to evolve into something that runs Wordpress. I’m more likely to continue adding bindings, features and solving the problems of PHP, rather than of some framework.
In a way, phpscript is the framework. And it’s not. The lines get blurry, and I like the term “special use”.
Final words
I’ve created and buried a few approaches to get to phpscript, to note the totals:
| Project | Description | State |
|---|---|---|
| titpetric/yamlexpr | This was my first entry into the world of interpretation. Too simplistic. | Dead |
| titpetric/vuego | I wrote a DOM based template engine. Too much overhead from DOM. | Dead |
| titpetric/lessgo | I wrote a Less CSS parser and compiler, compatible with LessJS. CSS3 has nesting. | Dead |
| titpetric/atkins | Wrote my own automation runner some time ago to replace yamlexpr. Could have been lefthook. |
Used |
| titpetric/oida | Started writing a status page, ended up with a telemetry package and log sink optimized for GC | Used |
| titpetric/gozero | Worked on gozero to minimize binding setup complexity, optimize binding invocation path, keep go type system | * |
| titpetric/task-ui | A web based shell/CI/CD/IDE environment with integrations for SSH, Forgejo, Bezsel, OpenCode, Claude | WIP |
- The asterisk: gozero is very specific and could be used in many low-latency processes that can be stated with imperative code execution. A demonstrated case are hot loaded test fixtures, but a more likely use case is some kind of policy-decision engine, a WAF, a rate limiter that doesn’t bottleneck your service.
I think I was solving the problem of having a node coupling at all to do web development. Building these tools has informed me both on how the Go runtime works, how the GC behaves, what to do when performance just isn’t good enough. I’ve learned over the course of this the limitations of these approaches and moved on.
Future work has little to do with the programming language itself, but rather using it more effectively:
- Create a mapping of go builtins onto the PHP syntax language, keep go type system, issue 185,
- Figure out a better system for integrating the Go
net/httpmux to go code, issue 186, - Enhancements in multi-tenancy, issue 116
- Security documentation, possible addition of secrets management, issue 139
I never imagined the biggest problem in having a runtime is how to package and distribute code for software composition, but here I am, writing programming language runtimes, observability stacks, deployment stacks, workflows with agentic assistance and wondering what kind of composition makes sense, how can I get out of writing a package manager. I don’t think I will, because in some way, extending functionality is easier now.
I don’t know what (if anything) you want to say to all this, but let’s talk. I’m open to work and new problems. If you want to look at phpscript further, the language reference should be a good start.
0 comments
No comments yet.
Sign in to comment.