Call PHP the Clojure Way
Phel runs on PHP. Every PHP class, function and Composer package is one call away. That is the reason to pick Phel over another Lisp.
But the calls used to look like nothing else. You wrote (php/new ...) to build an object, (php/-> obj (method ...)) to call it, and (php/:: Class (method ...)) for a static method. Three forms to learn. None of them looked like Clojure, so what you knew from Clojure did not help.
Now PHP interop reads the way Clojure reads Java. new builds, .method calls, Class/member reaches the static side. Every PHP member has one spelling.
Learn it once in Clojure. Use it in Phel.
new builds an object, and so does a trailing dot
new takes a class and its constructor arguments. A dot after the class name does the same thing:
(def launch (new DateTimeImmutable "2026-08-14"))
(.format launch "Y-m-d")
; => "2026-08-14"
(.format (DateTimeImmutable. "2026-08-14") "l")
; => "Friday"
Global PHP classes work as they are. For a namespaced class, import it with :use and write the namespace with dots. You name the namespace once, at the top:
(ns app.dice
(:use Random.Randomizer)
(:use Random.Engine.Mt19937))
(def dice (Randomizer. (Mt19937. 42)))
(.getInt dice 1 6)
; => 1
The same works for any class Composer installs. Phel loads vendor/autoload.php for you.
A leading dot calls a method, and -> chains the calls
(.method obj args) is $obj->method(args). The method name is part of the symbol, so it reads like a normal function call. That means it also fits the threading macros:
(def launch (new DateTimeImmutable "2026-08-14"))
(-> launch
(.modify "+1 day")
(.format "D, d M"))
; => "Sat, 15 Aug"
Compare the PHP it compiles to:
$launch->modify("+1 day")->format("D, d M");
Same order, same steps. And a method call is an ordinary form, so you can put it inside #(...) and pass it to map:
(->> ["2026-08-14" "2026-12-25"]
(map #(DateTimeImmutable. %))
(map #(.format % "D")))
; => ("Fri" "Fri").-field reads a property, and set! writes one
A dash after the dot reads a public property. set! assigns it:
(def interval (DateInterval. "PT30S"))
(.-s interval)
; => 30
(def box (new stdClass))
(set! (.-count box) 1)
(.-count box)
; => 1
set! mutates a PHP object in place. Phel data never changes, PHP objects do. Keep these calls at the edge of your code, where Phel meets a library.
A slash reaches static members, constants and enum cases
Class/member is the static side of a class: a static method, a class constant, or an enum case.
(.format (DateTimeImmutable/createFromFormat "Y-m-d" "2020-03-22") "Y-m-d")
; => "2020-03-22"
DateTimeImmutable/ATOM
; => "Y-m-d\\TH:i:sP"
(.-name RoundingMode/HalfEven)
; => "HalfEven"
An enum case is an object, so .-name reads its name like any other property. Static properties work too, and (set! Class/field v) assigns one.
Plain PHP functions keep their php/ prefix: (php/strlen "abc"). So do global constants such as php/PHP_EOL. The prefix tells you the name comes from PHP, not from Phel.
The editor knows these forms. The language server completes and hovers methods, properties, static members and PHP superglobals such as php/$_SERVER.
The old forms are gone
In Phel 0.50 the old spellings started to print a deprecation warning. Since 0.52 they don't compile. This is the old syntax, shown here only so you can recognise it:
(php/new DateTimeImmutable "2026-08-14")
Phel 0.53 stops at it and names the fix:
[PHEL012] "php/new" is no longer valid source for constructing a PHP object. Use "(new \Foo arg)" or "(\Foo. arg)" instead.
The move is mechanical. php/new becomes new, (php/-> obj (m a)) becomes (.m obj a), and (php/:: Foo (m a)) becomes (Foo/m a). The design is in ADR 0007.
Also in Phel 0.50
0.50 was the clean-up before 1.0. Everything that printed a deprecation notice in 0.49 is gone.
- Breaking: the old reader syntax is removed.
#comments become;,|(...)becomes#(...)with%,,and,@become~and~@, andfoo$gensyms becomefoo#. - Breaking:
,is now whitespace, as in Clojure. An old macro that unquotes with,still compiles and quietly quotes its argument. Run the grep from the upgrade notes. - Breaking: the old core aliases are removed:
push,put,unsetandvaluesare nowconj,assoc,dissocandvals. - Breaking: core functions declare real arities. A wrong argument count is an error, and
(max)with no arguments fails at compile time. - Breaking: lazy sequences print as
(2 3 4), not@[2 3 4]. - Breaking:
phel index --outis now--output, andphel config --jsonis now--format=json. sort-bycalls its key function once per element, not twice per comparison.phel balancefinds unbalanced(),[]and{}, and--fixappends the missing closers.phel benchrunsdefbenchbenchmarks, stores a baseline and fails a run that regresses.- Real arities make calls faster:
partial30x,fnil50x,sorton strings 22.7x. - Go to definition works on local bindings from
let,fn,loopand friends.
One spelling per idea. The one Clojure already taught you.