{"slug": "embedding-phel-into-a-symfony-project-ring-style-phel-owns-the-application-is", "title": "Embedding Phel into a Symfony project, Ring-style (Phel owns the application, Symfony is just the HTTP container)", "summary": "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.", "body_md": "# Phel on Symfony, Ring-style — TLDR\n\nEmbed [Phel](https://phel-lang.org) 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.\n\n**Full demo repo, tests, docs, migration guide, REPL cookbook:** <https://github.com/Chemaclass/phel-symfony-demo>\n\n## Stack\n\n| | |\n|---|---|\n| PHP | `>=8.4` |\n| Phel | `^0.38` |\n| Symfony | `7.4.*` (LTS) |\n| Doctrine DBAL | `^4` (no ORM) |\n\n## Architecture\n\n```\nSymfony FrameworkBundle (kernel, DI, one catch-all route)\n   |\n   v\nPhelApp adapter  ::  Symfony Request -> Phel map / Phel response -> JsonResponse\n   |\n   v\nphel.router/handler  ::  (fn [request] response)\n   |\n   +- middleware (wrap-errors, wrap-json-response)\n   +- route table (data)\n   +- handler fns (pure)\n   +- persistence ns (PHP boundary, returns result-tagged maps)\n```\n\nUses 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.\n\n## Quickstart\n\n``` bash\ngit clone https://github.com/Chemaclass/phel-symfony-demo && cd phel-symfony-demo\nmake install      # composer install + seeds SQLite (idempotent)\nmake serve        # http://127.0.0.1:8765\nbash\ncurl http://127.0.0.1:8765/users\ncurl http://127.0.0.1:8765/users/1\ncurl -X POST -H 'Content-Type: application/json' \\\n     -d '{\"email\":\"grace@example.com\",\"name\":\"Grace Hopper\"}' \\\n     http://127.0.0.1:8765/users\n```\n\n## The smallest end-to-end\n\n**Routes are data** (`src/Phel/main.phel`, ns `app.main`):\n\n``` clojure\n(def app\n  (r/handler\n    (r/router\n      [[\"/users\"      {:get  {:handler app.handlers/list-users}\n                       :post {:handler app.handlers/create-user}}]\n       [\"/users/{id}\" {:get  {:handler app.handlers/show-user}}]])\n    {:middleware [wrap-errors wrap-json-response]}))\n```\n\n**Handler is pure** (`src/Phel/handlers.phel`):\n\n``` clojure\n(defn show-user [req]\n  (let [conn (get-in req [:attributes :conn])\n        id   (php/intval (get-in req [:attributes :match :path-params :id]))\n        r    (db/find-user conn id)]\n    (case (get r :tag)\n      :ok        {:status 200 :body (get r :user)}\n      :not-found {:status 404 :body {:error \"not found\"}})))\n```\n\n**Persistence walls off PHP interop** (`src/Phel/persistence.phel`):\n\n``` clojure\n(defn find-user [conn id]\n  (if-let [row (php/-> conn (fetchAssociative\n                              \"SELECT id, email, name FROM users WHERE id = ?\"\n                              (php/array id)))]\n    {:tag :ok :user row}\n    {:tag :not-found}))\n```\n\n**System map (component-lite)** (`src/Phel/system.phel`):\n\n``` clojure\n(defn build [conn]\n  {:conn  conn\n   :clock (fn [] (php/time))})\n```\n\n**Test as data literals, no HTTP** (`tests/Phel/handlers_test.phel`):\n\n``` clojure\n(deftest show-user-returns-404-when-missing\n  (with-mocks [db/find-user (mock {:tag :not-found})]\n    (let [resp (hdl/show-user\n                 {:attributes {:conn :stub\n                               :match {:path-params {:id \"999\"}}}})]\n      (is (= 404 (get resp :status))))))\n```\n\n## Four ideas\n\n1. **Data > functions > macros.** Routes, schemas, system map — all data. Diffable. Composable. Inspectable in the REPL.\n2. **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`).\n3. **System map composes deps once.** `app.system/build` returns `{:conn ... :clock ... :logger ...}`. Adapter passes it under `:attributes`. Tests stub with a literal map.\n4. **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.\n\n## Symfony POV → Phel\n\n| Symfony idea | Phel equivalent |\n|---|---|\n| Controller class + method | `(fn [req] resp)` |\n| `Request` object | map with keyword keys (`:method`, `:uri`, `:parsed-body`, ...) |\n| `Response` object | map `{:status 200 :body ... :headers {...}}` |\n| Routing attributes | vector of `[path opts]` pairs (data) |\n| Middleware / EventSubscriber | `(fn [handler req] ...)` wrapping next handler |\n| Service container | values under `:attributes` in request map |\n| DTO / entity | plain map |\n| ORM repository | namespace of fns over `conn` |\n\n## REPL-driven dev (biggest mindset shift from PHP)\n\n``` bash\nmake repl\nclojure\n(require 'app.handlers :as hdl)\n(require 'app.validation :as v)\n\n(v/validate {\"email\" [:required :email]} {\"email\" \"x\"})\n;; => {:tag :error :errors {\"email\" \"email is invalid\"}}\n\n;; edit a fn, reload, retry — no boot, no curl\n(require 'app.handlers :reload)\n```\n\nCookbook: <https://github.com/Chemaclass/phel-symfony-demo/blob/main/docs/REPL.md>\n\n## Use any PHP package from Phel\n\n``` clojure\n;; Doctrine DBAL, Symfony Messenger, Twig, Doctrine ORM, ...\n(php/-> conn (fetchAssociative \"SELECT ...\" (php/array id)))\n(php/-> bus  (dispatch (php/new App\\Message\\SendEmail to subject)))\n(php/:: SomeClass staticMethod arg)\n```\n\nRule: keep these in a boundary namespace, not in handlers.\n\n## DX gotchas\n\n1. **Two `Phel` classes.** `\\Phel` (root ns) for helpers — `\\Phel::map(...)`, `\\Phel::keyword(...)`. `\\Phel\\Phel` for runtime — `Phel::bootstrap`, `Phel::run`.\n2. **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.\n3. **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'), ..., ...)`.\n4. **`(php/array ...)` is positional.** For DBAL `insert(table, data)` use `(php-associative-array \"email\" v \"name\" v)`.\n5. **Cache after edits.** `make cache-clear` after editing a `.phel` file.\n6. **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`.\n\n## Migration from existing Symfony controllers\n\nIncremental, never big-bang. PHP class survives as one-line delegation until you delete it. Full walkthrough:\n<https://github.com/Chemaclass/phel-symfony-demo/blob/main/docs/MIGRATION.md>\n\n## License\n\nMIT.\n", "url": "https://wpnews.pro/news/embedding-phel-into-a-symfony-project-ring-style-phel-owns-the-application-is", "canonical_source": "https://gist.github.com/Chemaclass/ceeed2eb4562186e0c968d5c70cb727b", "published_at": "2026-05-17 00:00:13+00:00", "updated_at": "2026-05-21 14:40:15.207232+00:00", "lang": "en", "topics": ["open-source", "developer-tools", "enterprise-software"], "entities": ["Phel", "Symfony", "Clojure", "Chemaclass", "Grace Hopper"], "alternates": {"html": "https://wpnews.pro/news/embedding-phel-into-a-symfony-project-ring-style-phel-owns-the-application-is", "markdown": "https://wpnews.pro/news/embedding-phel-into-a-symfony-project-ring-style-phel-owns-the-application-is.md", "text": "https://wpnews.pro/news/embedding-phel-into-a-symfony-project-ring-style-phel-owns-the-application-is.txt", "jsonld": "https://wpnews.pro/news/embedding-phel-into-a-symfony-project-ring-style-phel-owns-the-application-is.jsonld"}}