DEV Community

Cover image for 🐘 TypeScript for PHP? YES, that's possible! And it's not what you think...
Kevin 心学
Kevin 心学

Posted on • Edited on

🐘 TypeScript for PHP? YES, that's possible! And it's not what you think...

Combines strict typing with cheap $5 VPS hosting

It is time to bring opposites together, to stir up these stagnant waters, and to restore an industry that is dynamic from within, with multiple connections that were previously impossible or unimaginable.


In the previous parts of this series, we explored how PureScript acts as a quiet rewrite of the Web, offering a territory of mathematical precision above JavaScript, and how its universal polymorphism allows it to target wildly different runtimes like Node.js, Erlang's BEAM, Python, Go, Scheme, etc.

But hey, what about the elephant in the room? Yes, what is running the majority of the web, today, in the shadow? You know. The eternal one, the big one, the one whose death everyone talks about, but who never truly dies.

Totoro in the shadows

PHP.

While the modern JavaScript ecosystem (among others) races toward edge computing and containerized microservices, around 70% of the web is still powered by PHP. From legacy WordPress sites to affordable shared hosting (cPanel, OVH, GoDaddy, basic LAMP stacks), a massive portion of the internet lives in environments where escaping the PHP runtime is either financially unviable, technically impossible, or simply unnecessary. And I haven't even mentioned powerful and mature ecosystems like Laravel, Symfony, etc. They have proven their worth time and again over the years.

And yet...

For a long time, the PHP ecosystem has been left without a true, strictly typed functional and safe alternative, like TypeScript for Javascript. Even though recent PHP versions introduced fantastic features (short closures, match expressions, Fibers), the frustration that comes with its historical limitations and typing remains very real for some of its users.

Oh yes, there is Hack, and there are phenomenal static analyzers like PHPStan... but Hack eventually required a custom runtime, and PHPStan (as powerful as it is) is ultimately trying to bolt safety onto a fundamentally permissive and imperative language through comments.

What we are talking about here is a true paradigm shift in safety and ergonomics, keeping the great portability of PHP, as it is.

Very well!

This is why I built phpurs: a brand new compiler backend that transpiles PureScript directly to modern PHP 8.4+ syntax.

phpurs

It is no longer a proof of concept. It is passing the official PureScript test suite, and it is ready for production.

Here is how we bring the elegance of purely functional programming to the big web, while keeping deployment as simple as a legacy FTP upload, or anything alike.


The easy deployment paradox

In modern DevOps, deploying an app often means configuring Docker containers, CI/CD pipelines, Kubernetes, or serverless edge functions. It is powerful, but it is expensive and sometimes... a bit too complex for the real need. Let's be clear: I have nothing against it, I use it every day. But it's not always necessary. A website can run just fine on a very low cost server, possibly a shared one, without needing such an arsenal of tools.

For many freelancers and agencies, the reality is different indeed: clients often have a simple, small and cheap shared hosting plan. They want a robust application, but they don't want to pay for a managed AWS infrastructure.

Small Totoro

So... phpurs bridges this gap. It allows you to write your entire business logic, API, and background processes in 100% pure, strictly typed PureScript. And you get all the guarantees of ADTs, pattern matching, the Aff monad, HKTs... and so much more... (some concepts will be new to some of you: don't be intimidated, they're very practical).

Then, you compile it, and you get plain .php files. No overhead. The compiler has pruned what the runtime won't need, and optimized everything (e.g. Tail-Call Optimization, Dead-Code Elimination, Uncurrying...).

You just drag and drop these files via FTP to your client's cheap HostGator, OVH server, or whatever... and it works. No container, no daemon, no reverse proxy. Just native execution.

One of the biggest strengths of PHP is its portability. We leverage it.


Architecture: standing on the shoulders of giants

In fact, phpurs doesn't reinvent the entiiiire wheel. It stands on the incredible work of the PureScript community (e.g. Arista's purescript-backend-optimizer).

On the shoulders

The compilation pipeline is beautifully decoupled:

  1. At first, purs compiles your code to corefn.json.
  2. The backend-optimizer reads this CoreFn and performs aggressive dead code elimination (DCE), inlining, uncurrying, etc.
  3. At the end, phpurs takes this highly optimized intermediary AST and prints it as modern PHP syntax. And of course, it will add additional and sophisticated optimizations for PHP's sake.

Concurrency: Aff meets PHP Fibers

One of the biggest historical flaws of PHP was its synchronous, blocking nature. How do you implement PureScript's asynchronous Aff monad in such an environment?

Well, PHP 8.1 introduced Fibers, and the Revolt event loop followed.

Support for Aff has been natively added to phpurs. The behavior of the event loop in the compiled PHP code perfectly mirrors its Node.js counterpart: asynchronous operations are handled transparently without blocking the OS thread. The main process will automatically wait for all pending Aff tasks to complete before exiting, for example.

You can seamlessly run your code with a JS runtime, or a PHP one. You can even switch from one to another, when you're coding.

There is no need for complex daemonized C or Go extensions like Swoole or FrankenPHP. By relying exclusively on native PHP Fibers and asynchronous libraries (like Amphp), phpurs ensures that your concurrent PureScript code can run natively anywhere PHP runs. It just works, and handles multiple requests, like a good ol' Express server in Node.


Moving complexity from runtime to comptime

In standard PHP or JS development, safety often comes at the cost of defensive programming at runtime: checking boolean flags, instantiating heavy wrapper classes, or placing if (!$form->isValid()) throw new Exception(...) checks throughout your domain logic.

Like TypeScript, PureScript flips this paradigm. But it goes further, with a powerful Hindley-Milner type system. Once the PureScript compiler validates your logic, the underlying compiled PHP is freed from defensive checks, leaving a trivial, lightning-fast execution path.

For example, consider a form processing pipeline where you must guarantee that data is validated before saving it to a database. Using Phantom Types, we can tag the state of our data (Unchecked vs Checked).

-- 1. Phantom Types
--    They exist ONLY for the compiler, zero runtime cost.
data Unchecked
data Checked

newtype FormData status = FormData { email :: String, age :: Int }

-- 2. Construction: Creates an `Unchecked` form
createForm :: String -> Int -> FormData Unchecked
createForm email age = FormData { email, age }

-- 3. Smart Constructor.
--    Only validation transforms `Unchecked` into `Checked`
validate :: FormData Unchecked -> Either String (FormData Checked)
validate (FormData d) = 
  if d.age >= 18 && contains (Pattern "@") d.email
  then Right (FormData d)
  else Left "Invalid form data"

-- 4. Database operation ONLY accepts a `FormData Checked`!
saveToDb :: FormData Checked -> Effect Unit
saveToDb (FormData d) = ...
Enter fullscreen mode Exit fullscreen mode

It is mathematically impossible to call saveToDb with unvalidated data (FormData Unchecked). If you try, the compiler halts with a type error before any code is ever generated.

In PHP, we could translate what's above into something like that:

// 1. We must define wrapper classes and mutable state flags
class FormData {
    public function __construct(
        public readonly string $email,
        public readonly int $age,
        public bool $isValidated = false // ⚠️ Runtime state to track!
    ) {}
}

// 2. Construction: creates an "unchecked" form
function createForm(string $email, int $age): FormData {
    // We instantiate a real class object in memory
    return new FormData($email, $age); 
}

// 3. Validation mutates the state or throws Exceptions
function validate (FormData $data): void {
    if ($data->age < 18 || !str_contains($data->email, '@')) {
        throw new Exception("Invalid form data"); // ⚠️ Runtime defensive throw
    }
    $data->isValidated = true; // ⚠️ Runtime mutation
}

// 4. Database operation MUST defensively check the state
function saveToDb (FormData $data): void {
    // ⚠️ Defensive check at runtime: did the developer call validate() before?
    if (!$data->isValidated) { 
        throw new Exception("Security error: Data not validated!"); 
    }

    // ... 
}
Enter fullscreen mode Exit fullscreen mode

Of course, advanced PHP developers might say they could use strict Value Objects (creating a separate ValidatedFormData class) or advanced PHPStan docblocks to avoid the boolean flag. And they'd be completely right. I did it too, for years.

But Value Objects add memory allocations and mapping overhead at runtime, while PHPStan only enforces safety through comments, not rock-solid compiler guarantees. More generally speaking, existing solutions are often smart compromises.

PureScript gives you the best of both worlds: strict, mathematically proven safety at compile-time, that compiles down to raw, zero-overhead unboxed objects at runtime. I simplified it for the example, but now, look at the compiled PHP code phpurs outputs for the entire pipeline:

// 1. Creating a `FormData Unchecked` simply returns a raw PHP object.
//    The `FormData` newtype wrapper is completely unboxed!
$createForm = function ($email = null, $age = null) {
    return (object)["email" => $email, "age" => $age];
};

// 2. Validation checks the raw data and wraps it in `Right`.
//    No state classes or newtypes are instantiated!
$validate = function ($formData = null) {
    if ($formData->age >= 18 && str_contains($formData->email, "@")) {
        return new Data1("Right", $formData);
    }
    return new Data1("Left", "Invalid form data");
};

// 3. Database save has ZERO defensive checks (no `$data->isValidated`)
//    PureScript guaranteed 100% safety at compile-time.
$saveToDb = function ($formData = null) {
    ...
};
Enter fullscreen mode Exit fullscreen mode

Zero defensive runtime overhead. No boolean flags, no try/catch blocks, no dynamic state assertions inside $saveToDb.
Zero object allocations: the Phantom Types (Unchecked/Checked) and FormData newtype wrapper don't instantiate any PHP classes. At runtime, $formData is just a plain, raw PHP object seamlessly passed from $createForm to $saveToDb.

Complexity lives strictly in the compiler. What runs on your host is clean and fast.


What about performance?

It would be a legitimate question. For example, PHP is often criticized for its raw execution speed compared to V8 (Node.js).

In raw micro-benchmarks, PHP 8 is indeed about 2 to 3 times slower than V8 for heavy computational tasks. However, in the vast majority of real-world web applications, the main bottleneck is I/O (database, network, filesystem).

Running Totoro

The difference in performance is entirely negligible in practice. You lose almost nothing in speed, but you gain the ability to deploy your code on any server in the world for pennies.

I recently battle-tested phpurs against the test suite of a real-world project (still a WIP, but the code is mature). The results are clear: in practice, it is only x1.15 slower. Of course, the value of this factor will depend on your very specific project(s). But remember: PHP is not the preferred backend for performance, it is for portability.


What about interoperability? The entire PHP ecosystem at your fingertips

You might be thinking: "This is great, but do I have to rewrite the entire world in PureScript? What about my existing PHP libraries, the AWS SDK, the Symfony components I rely on... ?"

Fear not. PureScript has always been designed with a very pragmatic approach to the outside world, and phpurs fully embraces this philosophy through its Foreign Function Interface (FFI).

You are not locked in a pure, isolated bubble. If you need to use a powerful, battle-tested PHP library, you can easily bind it to your PureScript code. The bridge between the two worlds is incredibly straightforward: you simply declare the type signature in PureScript, and provide the implementation in a standard .php file.

-- In PureScript: src/Crypto.purs
-- We declare the function and its type
foreign import hashPassword :: String -> Effect String
Enter fullscreen mode Exit fullscreen mode
// In PHP: src/Crypto.php
// We implement the runtime behavior
$hashPassword = function (string $password): callable {
    return function () use ($password): string {
        return password_hash($password, PASSWORD_BCRYPT);
    };
};
Enter fullscreen mode Exit fullscreen mode

That is all it takes. This simple mechanism allows you to tap into the gigantic Packagist ecosystem. You can leverage Symfony's HttpFoundation, Laravel's Eloquent ORM, Guzzle... anything, while keeping the core of your domain logic purely functional and mathematically proven.

Moreover, since the compiled output is just standard PHP, it plays perfectly well with Composer. This means you can incrementally introduce PureScript into an existing, massive legacy codebase. No need for a Big Bang rewrite: you can start by migrating your most critical, bug-prone business logic first, and let it interact seamlessly with your existing PHP architecture. In both directions: your PureScript modules can seamlessly tap into your existing Composer dependencies via FFI, and/or your legacy PHP codebase can simply require the compiler's output (i.e. PureScript code compiled into PHP code) to execute your newly written functions.


The next frontier

I am already using it in production for several client projects with strictly PHP-only environments, and the runtime stability is rock solid (and super fast). 🟢

Pure dev pleasure, with pragmatic client constraints. Getting the best of both worlds is a very, very pleasant feeling.

Happy Totoro

Community contributions are highly welcome! If you want to help add missing PHP FFIs to core and major PureScript libraries, your PRs are more than welcome.

Let's bring the elegance of PureScript to the 70% of the web that still runs on PHP! 🐘

Cheers 👋

Edit: A huge thanks to PHP Weekly for featuring this post! That's a delightful surprise. I'm really glad to see this approach resonating with the PHP community!

👉 The Compiler

👉 PureScript Fullstack example, able to run on PHP runtime

Top comments (8)

Collapse
 
publiflow profile image
PubliFlow

Bringing strong static typing paradigms from the JavaScript ecosystem into PHP is a fascinating approach, especially when considering tools that bridge the two. I have often wondered if compiling PureScript or TypeScript directly to PHP bytecode would actually yield better performance than traditional transpilation to JavaScript. Have you tested the runtime overhead of the generated PHP code compared to native PHP implementations? It would be interesting to see if the type safety benefits outweigh the potential execution penalties in a production environment.

Collapse
 
0x1 profile image
Kevin 心学

Hi @publiflow, thank you for the feedback. You raise a crucial point.

Indeed, that is the whole challenge of a good compiler: not sacrificing the expressive experience of the source language, while getting as close as possible to the native performance of the target runtime (whether it's Go, JS, or PHP).

I briefly touched on this in the "What about performance?" section of the article. For a real-world web project, the overhead is negligible (around x1.15), simply because the main bottleneck is almost always I/O (database, network) rather than raw computation.

However, to answer your question more precisely regarding pure computational execution, I actually put together a comprehensive benchmark that puts each compiler backend to the test on heavy algorithms (Fibonacci, AST evaluation, deep record updates, tail calls, etc.).

You can check out the detailed results here:
👉 Core Benchmark Results (Pure Computational)

As you can see in these stress tests, we have an average factor of about x4 between JS (Node/V8) and PHP. That's a factor we often see online, on pure computational benchmarks. So while there is an execution penalty compared to V8, the compiler performs deep optimizations (dead-code elimination, inlining, uncurrying) before outputting the PHP files, keeping the generated code fast. Of course, at some point, the compiler inevitably hits the fundamental speed limits of the target language.

This is exactly why PureScript is interesting. We can choose target runtimes for their specific strengths (resilience, speed, portability, lightweight execution...), rather than expecting one single runtime to do everything perfectly. One language, multiple runtimes.

I've also made gopurs to target Go, for pure performance. This is in the benchmark results table.

In a production environment where PHP would be the preferred target, most web applications act more like orchestrators than heavy number-crunchers. If a true computational "hot path" does emerge, and if the thin compiler overload is annoying, the best practice is simply to offload that specific logic to a specialized native PHP FFI, as explained in the article.

I hope I've been concise yet clear.

Cheers! 🙂

Collapse
 
publiflow profile image
PubliFlow

Balancing TypeScript's expressive type system with PHP's dynamic runtime is exactly the right way to frame the compiler's main challenge. I am curious if the transpiler handles complex generics or utility types by unrolling them into concrete PHP classes, or if it relies on runtime wrappers instead. Exploring how the AST transformation tackles those specific edge cases would make for a highly technical follow-up post.

Thread Thread
 
0x1 profile image
Kevin 心学 • Edited

Great question, once again!

You’ve perfectly highlighted the classic compiler design dilemma when targeting a dynamic language: do we unroll generics into concrete classes (which causes massive code bloat and memory overhead in PHP), or do we rely on runtime wrappers (which kills CPU performance and heavily taxes the Garbage Collector)?

The answer is actually: neither! phpurs relies on pure type erasure.

Here is how it happens: the compiler backend doesn't use the standard PureScript CoreFn. Instead, it relies on a custom fork that produces a TAST (Typed Abstract Syntax Tree) called tcorefn (the PR is being prepared for the official purescript repo). In this TAST, structural type information is never erased during the analysis phase. The compiler has absolute knowledge of the data structures and memory layouts at every call site. You can find more info here, in my gopurs devlog.

Because the compiler mathematically proves the safety of the entire program at compile-time (thanks to the Hindley-Milner type system), we don't need to carry any of that defensive complexity into the final PHP execution.

When generating the PHP files, the generics are completely erased. A generic type like List a or Maybe a simply becomes a raw, unboxed PHP array or a plain stdClass. There are no concrete classes generated for specific generic instantiations, and absolutely zero runtime wrappers.

However, there is one highly strategic exception: primitive types (ints, strings, booleans...) are explicitly preserved as native PHP type hints in function signatures. By doing this, we feed PHP 8's JIT compiler exactly the context it needs to instantly infer types, skip dynamic guards, and emit highly optimized machine code for computational hot-paths.

This is where the TAST changes everything: it allows the compilation strategy to adapt entirely to the target runtime. For instance, in my other compiler gopurs (which targets Go), we do the exact opposite. Because Go is statically compiled, erasing types into interface{} would destroy performance. Instead, gopurs uses the exact same TAST to generate strictly typed structs, allowing Go to unbox them on the stack.

But in PHP, typing "structs" (generating concrete classes with typed properties) would choke the Zend Engine with class autoloading, object allocations, and redundant runtime checks. By doing pure type erasure (except for primitives), we let PHP do exactly what its C-based engine is highly optimized for: processing raw associative arrays and dynamic scalars at lightning speed, with zero overhead.

You are completely right: exploring how the TAST drives the compiler transformation would also definitely make for a great highly technical follow-up post. In it, I'll credit your question as the inspiration source. It is indeed a very interesting topic that deserves more detail. Thanks for bringing it up!

Thread Thread
 
publiflow profile image
PubliFlow

You left me hanging right at the climax! The tradeoff between monomorphization bloat and runtime wrapper overhead is exactly why compiling to a dynamic target is notoriously difficult. I am very curious to hear what alternative mechanism phpurs uses to completely bypass this dichotomy.

Collapse
 
hosseinyazdi profile image
Hossein Yazdi

Interesting project. I like that it isn't trying to replace PHP, it lets people keep PHP's deployment simplicity while getting stronger compile-time guarantees through PureScript. The interoperability with existing Composer libraries is probably what makes it practical.

Collapse
 
khapu2906 profile image
Kent Phung

This resonates a lot with me. I feel like our industry sometimes over-engineers infrastructure before we've even validated the product.
Using PHP's portability while getting PureScript's guarantees is a really interesting trade-off. Most MVPs would probably benefit more from "drag & drop to a $5 VPS" than another Kubernetes cluster.
Sometimes boring infrastructure is the best infrastructure.

Collapse
 
publiflow profile image
PubliFlow

The idea of bringing TypeScript strict typing paradigms to PHP is fascinating, especially since PHP 8 has already been moving heavily in that direction with union types and enums. It really makes you think about how much the JS ecosystem tooling could revolutionize backend architectures if bridged correctly. If you are looking to build modern, type-safe full-stack apps without the headache of bridging two entirely different language paradigms, I highly recommend checking out PubliFlow at publiflow.vip for a solid Next.js and Supabase foundation.