cd /news/open-source/embedding-phel-into-a-symfony-projec… · home topics open-source article
[ARTICLE · art-5496] src=gist.github.com ↗ pub= topic=open-source verified=true sentiment=↑ positive

Embedding Phel into a Symfony project, Ring-style (Phel owns the application, Symfony is just the HTTP container)

Software architecture pattern for embedding the Phel language (a Lisp dialect that compiles to PHP) into a Symfony project, where Phel owns the routing, business logic, and handlers while Symfony is relegated to the edge as an HTTP container providing dependency injection and configuration. The approach uses pure handlers over plain request/response maps, a data-driven router, result-tagged returns instead of exceptions, and a system map for dependency composition, with PHP interop strictly walled off in boundary namespaces. A full demo repository is provided with tests, documentation, a migration guide, and a REPL cookbook.

read4 min views18 publishedMay 17, 2026

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 #

  1. Data > functions > macros. Routes, schemas, system map — all data. Diffable. Composable. Inspectable in the REPL.
  2. Result-tagged returns, not exceptions. find-user{:tag :ok :user m} | {:tag :not-found}. Handlers branch with case. Exceptions only at the edge ( wrap-errors).
  3. System map composes deps once. app.system/build returns {:conn ... :clock ... :logger ...}. Adapter passes it under :attributes. Tests stub with a literal map.
  4. PHP interop walled off. Only *.persistence, *.system, and PhelApp.php touch PHP objects. Handlers stay pure and stub-friendly. grep php/ handlers.phel should 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 #

  1. Two Phel classes. \Phel (root ns) for helpers — \Phel::map(...), \Phel::keyword(...). \Phel\Phel for runtime — Phel::bootstrap, Phel::run.
  2. Phel maps aren't JsonSerializable. Call (phel->php data) before JsonResponse, else body silently encodes as {}. Adapter resolves phel.core/phel->php once at boot.
  3. PHP assoc array != Phel keyword-keyed map. phel.http/request-from-map destructures by Keyword. Building with ['method' => ...] returns nil for every field. Use \Phel::map(\Phel::keyword('method'), ..., ...).
  4. (php/array ...) is positional. For DBAL insert(table, data) use (php-associative-array "email" v "name" v).
  5. Cache after edits. make cache-clear after editing a .phel file.
  6. Don't lint the src/Phel/ dir. Each file loads standalone; transitive :require triggers 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.

── more in #open-source 4 stories · sorted by recency
── more on @phel 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/embedding-phel-into-…] indexed:0 read:4min 2026-05-17 ·