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

> Source: <https://gist.github.com/Chemaclass/ceeed2eb4562186e0c968d5c70cb727b>
> Published: 2026-05-17 00:00:13+00:00

# Phel on Symfony, Ring-style — TLDR

Embed [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.

**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

``` bash
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`):

``` clojure
(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`):

``` clojure
(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`):

``` clojure
(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`):

``` clojure
(defn build [conn]
  {:conn  conn
   :clock (fn [] (php/time))})
```

**Test as data literals, no HTTP** (`tests/Phel/handlers_test.phel`):

``` clojure
(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)

``` bash
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

``` clojure
;; 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.

