Embed Phel into a Symfony app the way a Clojure dev would. Pure handlers over plain request/response maps. Symfony stays at the edge (HTTP, DI, config). Phel owns routing, handlers, business logic. Adapter ~130 LOC.
Full demo repo, tests, docs, migration guide, REPL cookbook: https://github.com/Chemaclass/phel-symfony-demo
Stack #
| PHP | >=8.4 |
| Phel | ^0.38 |
| Symfony | 7.4.* (LTS) |
| Doctrine DBAL | ^4 (no ORM) |
Architecture #
Symfony FrameworkBundle (kernel, DI, one catch-all route)
|
v
PhelApp adapter :: Symfony Request -> Phel map / Phel response -> JsonResponse
|
v
phel.router/handler :: (fn [request] response)
|
+- middleware (wrap-errors, wrap-json-response)
+- route table (data)
+- handler fns (pure)
+- persistence ns (PHP boundary, returns result-tagged maps)
Uses Phel's built-in phel.http (request/response structs) and phel.router (data-driven router with :middleware, path-param extraction). No custom router. No ORM bridging. Phel never imports Symfony.
Quickstart #
git clone https://github.com/Chemaclass/phel-symfony-demo && cd phel-symfony-demo
make install # composer install + seeds SQLite (idempotent)
make serve # http://127.0.0.1:8765
bash
curl http://127.0.0.1:8765/users
curl http://127.0.0.1:8765/users/1
curl -X POST -H 'Content-Type: application/json' \
-d '{"email":"grace@example.com","name":"Grace Hopper"}' \
http://127.0.0.1:8765/users
The smallest end-to-end #
Routes are data (src/Phel/main.phel, ns app.main):
(def app
(r/handler
(r/router
[["/users" {:get {:handler app.handlers/list-users}
:post {:handler app.handlers/create-user}}]
["/users/{id}" {:get {:handler app.handlers/show-user}}]])
{:middleware [wrap-errors wrap-json-response]}))
Handler is pure (src/Phel/handlers.phel):
(defn show-user [req]
(let [conn (get-in req [:attributes :conn])
id (php/intval (get-in req [:attributes :match :path-params :id]))
r (db/find-user conn id)]
(case (get r :tag)
:ok {:status 200 :body (get r :user)}
:not-found {:status 404 :body {:error "not found"}})))
Persistence walls off PHP interop (src/Phel/persistence.phel):
(defn find-user [conn id]
(if-let [row (php/-> conn (fetchAssociative
"SELECT id, email, name FROM users WHERE id = ?"
(php/array id)))]
{:tag :ok :user row}
{:tag :not-found}))
System map (component-lite) (src/Phel/system.phel):
(defn build [conn]
{:conn conn
:clock (fn [] (php/time))})
Test as data literals, no HTTP (tests/Phel/handlers_test.phel):
(deftest show-user-returns-404-when-missing
(with-mocks [db/find-user (mock {:tag :not-found})]
(let [resp (hdl/show-user
{:attributes {:conn :stub
:match {:path-params {:id "999"}}}})]
(is (= 404 (get resp :status))))))
Four ideas #
- Data > functions > macros. Routes, schemas, system map — all data. Diffable. Composable. Inspectable in the REPL.
- Result-tagged returns, not exceptions.
find-user→{:tag :ok :user m}|{:tag :not-found}. Handlers branch withcase. Exceptions only at the edge (wrap-errors). - System map composes deps once.
app.system/buildreturns{:conn ... :clock ... :logger ...}. Adapter passes it under:attributes. Tests stub with a literal map. - PHP interop walled off. Only
*.persistence,*.system, andPhelApp.phptouch PHP objects. Handlers stay pure and stub-friendly.grep php/ handlers.phelshould return zero.
Symfony POV → Phel #
| Symfony idea | Phel equivalent |
|---|---|
| Controller class + method | (fn [req] resp) |
Request object |
map with keyword keys (:method, :uri, :parsed-body, ...) |
Response object |
map {:status 200 :body ... :headers {...}} |
| Routing attributes | vector of [path opts] pairs (data) |
| Middleware / EventSubscriber | (fn [handler req] ...) wrapping next handler |
| Service container | values under :attributes in request map |
| DTO / entity | plain map |
| ORM repository | namespace of fns over conn |
REPL-driven dev (biggest mindset shift from PHP) #
make repl
clojure
(require 'app.handlers :as hdl)
(require 'app.validation :as v)
(v/validate {"email" [:required :email]} {"email" "x"})
;; => {:tag :error :errors {"email" "email is invalid"}}
;; edit a fn, reload, retry — no boot, no curl
(require 'app.handlers :reload)
Cookbook: https://github.com/Chemaclass/phel-symfony-demo/blob/main/docs/REPL.md
Use any PHP package from Phel #
;; Doctrine DBAL, Symfony Messenger, Twig, Doctrine ORM, ...
(php/-> conn (fetchAssociative "SELECT ..." (php/array id)))
(php/-> bus (dispatch (php/new App\Message\SendEmail to subject)))
(php/:: SomeClass staticMethod arg)
Rule: keep these in a boundary namespace, not in handlers.
DX gotchas #
- Two
Phelclasses.\Phel(root ns) for helpers —\Phel::map(...),\Phel::keyword(...).\Phel\Phelfor runtime —Phel::bootstrap,Phel::run. - Phel maps aren't
JsonSerializable. Call(phel->php data)beforeJsonResponse, else body silently encodes as{}. Adapter resolvesphel.core/phel->phponce at boot. - PHP assoc array != Phel keyword-keyed map.
phel.http/request-from-mapdestructures byKeyword. Building with['method' => ...]returnsnilfor every field. Use\Phel::map(\Phel::keyword('method'), ..., ...). (php/array ...)is positional. For DBALinsert(table, data)use(php-associative-array "email" v "name" v).- Cache after edits.
make cache-clearafter editing a.phelfile. - Don't lint the
src/Phel/dir. Each file loads standalone; transitive:requiretriggers duplicate-symbol errors. Lint the entry namespace:vendor/bin/phel lint src/Phel/main.phel.
Migration from existing Symfony controllers #
Incremental, never big-bang. PHP class survives as one-line delegation until you delete it. Full walkthrough: https://github.com/Chemaclass/phel-symfony-demo/blob/main/docs/MIGRATION.md
License #
MIT.