Phel is a functional Lisp on PHP, inspired by Clojure (and Janet). Persistent data structures, immutability by default, functional-first. This guide: what transfers, what differs, what Phel adds.
Ships with: protocols, transducers, reader conditionals (#?()), regex literals, ex-info/ex-data, hierarchies (derive, isa?, parents, ancestors, descendants), core.match-style match, schema, fiber-based async, nREPL/LSP toolchain.
What feels familiar#
Clojure intuition carries over.
Core forms: def, defn, let, fn, if, when, cond, case, do, loop/recur.
Persistent data structures: vectors, maps, sets. Same algorithms (HAMTs etc.), same core functions:
(def v [1 2 3])
(conj v 4) ; => [1 2 3 4]
(def m {:name "Alice" :age 30})
(assoc m :role :admin) ; => {:name "Alice" :age 30 :role :admin}
(get m :name) ; => "Alice"
(:name m) ; => "Alice" (keywords are functions)
(def s #{1 2 3})
(conj s 4) ; => #{1 2 3 4}
Threading macros: ->, ->>, as-> work as in Clojure:
(def users [{:name "Alice" :active true} {:name "Bob" :active false}])
(->> users
(filter :active)
(map :name)
(into #{}))
Destructuring: sequential and associative work in let, fn, defn, loop, same as Clojure. See Destructuring.
Higher-order functions: map, filter, reduce, some, every?, comp, partial, apply, etc.:
(map inc [1 2 3]) ; => @[2 3 4]
(filter even? [1 2 3 4]) ; => @[2 4]
(reduce + 0 [1 2 3 4 5]) ; => 15
Lazy sequences: full support, including infinite seqs, custom lazy-seq/lazy-cat, realization control (doall, dorun, realized?), and lazy file I/O. See Lazy sequences.
Namespaces: :require for Phel modules, :as and :refer like Clojure.
REPL: supports doc, inline require, multiline. See REPL.
Macros: defmacro, quote, syntax-quote, unquote, unquote-splicing. defn is a macro. defn supports metadata shorthands: ^:memoize wraps the body in memoize; ^:async wraps in async returning Amp\Future. See Macros.
Reference: Data Structures, Functions and Recursion.
Key differences#
Day-to-day differences.
No JVM, PHP runtime#
Compiles to PHP, runs on PHP. No JVM, classpath, JARs. Dependency manager is Composer (not deps.edn or Leiningen).
Protocols#
Phel supports Clojure-style protocols with defprotocol and extend-type. Use definterface + defstruct for simpler cases where you control the type:
(definterface Greetable
(greet [this]))
(defstruct person [name]
Greetable
(greet [this] (str "Hello, " name)))
(greet (person "Alice")) ; => "Hello, Alice"
See Interfaces for the full reference.
Multimethods#
Phel supports Clojure-style defmulti / defmethod with hierarchy-aware dispatch through the derive / isa? system:
(defmulti area :shape)
(defmethod area :circle [{:radius r}] (* 3.14159 r r))
(defmethod area :rectangle [{:width w :height h}] (* w h))Type tags and inference#
:tag metadata emits PHP type declarations: (defn ^int add [^int a ^int b] ...) compiles to function add(int $a, int $b): int, and ^"?string" marks a nullable type. Phel also infers return types from tail primitive ops, so you often don't need to annotate at all. See Functions for the tag and metadata shorthands (^:memoize, ^:async).
Numeric tower#
Phel ships BigInt, BigDecimal, and Ratio as first-class types:
1N ; BigInt literal
1.5M ; BigDecimal literal
1/2 ; Ratio literal (not a float, exact ratio)
(/ 1 2) ; => 1/2 (Ratio, exact)
(/ 1.0 2) ; => 0.5 (float)
(+ 1N 2N) ; => 3 (BigInt)
;; PHP ints auto-promote to BigInt on overflow
(* 9999999999999999999N 2N) ; stays exact
;; Constructors and predicates
(bigint 42) ; => 42
(bigdec "1.5") ; => 1.5M
(ratio? 1/2) ; => trueAtoms only, no agents/refs/STM#
atom is the only mutable primitive; atom, swap!, reset!, and deref/@ work exactly like Clojure's. No agents, refs, STM. See Global and Local Bindings.
No spec#
No clojure.spec. Phel ships phel.schema for validation, coercion, and generation. See the Schema Validation guide.
Truthiness#
Same as Clojure: only false and nil falsy. 0, "", [] truthy. Differs from PHP. See Truthiness.
Reader conditionals#
#?() and splicing #?@(), using :phel and :default as platform keys. Enables cross-platform .cljc files:
(def host
#?(:phel "PHP"
:default "Unknown"))
No custom reader macros. Generic tagged literals (#uuid, #inst, #cpp, etc.) read as tagged-literal nodes. Clojure-style #(...) with %/%1/%& works. Phel-only |(...) with $ accepted but deprecated. #_ to skip a form.
Syntax differences#
Clojure and Phel side by side for syntactically different constructs.
Namespace declaration#
Same . separator as Clojure. PHP class FQNs in :use use .:
;; Clojure
(ns myapp.users
(:require [myapp.db :as db]
[clojure.string :as str]));; Phel
(ns myapp.users
(:require myapp.db :as db))
Differences:
- No vector wrap per require clause
:usefor PHP classes;:requirefor Phel modules:refersame:(:require myapp.db :refer [query])- Backslash form
(ns myapp.db)still parses for legacy code, warns underPHEL_WARN_DEPRECATIONS=1
See Namespaces.
Keywords#
Same:
;; Clojure
:name
:my-key
::namespaced-key;; Phel
:name
:my-key
::namespaced-key
Keywords act as functions on maps in both: (:name user).
String concatenation and formatting#
str for concat, format for sprintf-style:
;; Clojure
(str "Hello, " name "!")
(format "Hello, %s! You are %d." name age);; Phel
(def name "Alice")
(def age 30)
(str "Hello, " name "!")
(format "Hello, %s! You are %d." name age)Anonymous functions#
fn and #(...) with % work the same:
;; Clojure
(fn [x] (* x 2))
#(* % 2)
#(+ %1 %2);; Phel
(fn [x] (* x 2))
#(* % 2)
#(+ %1 %2)
Phel accepts legacy |(...) with $, but #(...) is preferred.
See Functions and Recursion for multi-arity, variadic, recur.
Maps#
Both use {}. Keyword keys idiomatic:
;; Clojure
{:name "Alice" :age 30}
(get user :name)
(:name user)
(assoc user :role :admin);; Phel
(def user {:name "Alice" :age 30})
(get user :name)
(:name user)
(assoc user :role :admin)
Same syntax and functions. Phel maps also accept any hashable type as keys.
PHP interop (replaces Java interop)#
Use php/ prefix:
;; Clojure (Java interop)
(System/currentTimeMillis)
(.toUpperCase "hello")
(Math/pow 2 10);; Phel (PHP interop)
(php/time)
(php/strtoupper "hello")
(php/pow 2 10)
Any PHP function via php/ prefix. See PHP Interop.
Printing#
println adds newline, print doesn't:
;; Clojure
(println "Hello, world!")
(pr-str {:a 1});; Phel
(println "Hello, world!")
(str {:a 1})Comments#
Use ; and ;;. Legacy # line and #| ... |# block comments still read but deprecated. #_ skips a form:
;; Clojure
;; line comment
(comment (+ 1 2));; Phel
; line comment
;; standalone comment
#_(comment (+ 1 2)) ; skip the next form
(comment (+ 1 2))What you'll miss (and workarounds)#
CIDER / Calva / nREPL#
Editor tooling covers VS Code, PhpStorm, Emacs, Vim. Phel ships nREPL and LSP servers, structured stack frames in EvalError, stdout capture in EvalResult.
ClojureScript#
PHP only. No browser/JavaScript target.
deps.edn / Leiningen#
Use Composer. composer.json replaces deps.edn:
{
"require": {
"phel-lang/phel-lang": "^0.48",
"php": ">=8.4"
}
}core.async / concurrency primitives#
PHP is request-based, not long-running. No core.async, channels, CSP. Use PHP queues or process managers via interop.
What you'll gain#
Cheap, ubiquitous hosting#
PHP runs on virtually any web host, including shared hosting at a few dollars/month. No JVM-capable server.
Simpler deployment#
No JVM startup, heap tuning, GC config. Deploy like any PHP app: upload files or composer install.
Fast startup#
PHP processes start in milliseconds. No JVM warmup. CLI tools and short-lived scripts are practical.
PHP ecosystem#
Decades of battle-tested libraries via composer require: WordPress, Laravel, Symfony, Guzzle, PHPUnit, Doctrine, thousands more. All callable via php/.
Shared hosting#
Many orgs already run PHP. Bring FP/Lisp into environments where the JVM isn't an option.
Quick reference: Clojure to Phel#
| Clojure | Phel | Notes |
|---|---|---|
(ns foo.bar) | (ns foo.bar) | Same separator. PHP FQNs use . |
(:require [foo.bar :as b]) | (:require foo.bar :as b) | No vector wrapping required |
#(* % 2) | #(* % 2) | Same. |(* $ 2) legacy, deprecated |
(atom 0) | (atom 0) | Same |
@my-atom | @my-atom | Same |
(reset! a v) | (reset! a v) | Same (set! alias removed in 0.36) |
(swap! a f) | (swap! a f) | Same |
#'sym / (var sym) | #'sym / (var sym) | First-class Var handle |
(alter-var-root #'v f) | (alter-var-root #'v f) | Same |
(with-redefs [v x] ...) | (with-redefs [v x] ...) | Same. Works for non-dynamic vars |
(binding [*x* v] ...) | (binding [*x* v] ...) | Var must be ^:dynamic |
(.method obj) | (.method obj) or (php/-> obj (method)) | Both forms work |
(Class/staticMethod) | (Class/staticMethod) or (php/:: Class (staticMethod)) | Both forms work |
(new Class) | (Class.) or (php/new Class) | ClassName. shorthand |
^int tag | ^int tag | Emits PHP type declaration |
(memoize f) | ^:memoize on defn | Metadata shorthand |
(defprotocol P) | (defprotocol P) | Same |
(defrecord R) | (defrecord R) or (defstruct R) | Both available |
(lazy-seq ...) | (lazy-seq ...) | Same |
1N / 1.5M / 1/2 | 1N / 1.5M / 1/2 | BigInt / BigDecimal / Ratio |
(/ 1 2) → 1/2 (Ratio) | (/ 1 2) → 1/2 (Ratio) | Same; use (/ 1.0 2) for float |
#"regex" | #"regex" | Regex literal; re-find, re-matches |
#?(:clj x :default y) | #?(:phel x :default y) | Reader conditionals |
(ex-info msg data) | (ex-info msg data) | Same |
(transduce xf f coll) | (transduce xf f coll) | Same |
;; comment | ;; comment | ; and ;; standard |
Welcome to the PHP side of Lisp. The parentheses are the same; the runtime just happens to be PHP.
Next steps#
- Rosetta Stone: PHP to Phel - the PHP angle on the same forms
- Cookbook - copy-paste recipes to get productive fast
- PHP interop - the full interop reference