Skip to content

Instantly share code, notes, and snippets.

@Chemaclass
Last active May 21, 2026 14:39
Show Gist options
  • Select an option

  • Save Chemaclass/ceeed2eb4562186e0c968d5c70cb727b to your computer and use it in GitHub Desktop.

Select an option

Save Chemaclass/ceeed2eb4562186e0c968d5c70cb727b to your computer and use it in GitHub Desktop.
Embedding Phel into a Symfony project, Ring-style (Phel owns the application, Symfony is just the HTTP container)

Phel on Symfony, Ring-style — TLDR

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
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
(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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment