You can drop Phel into an existing PHP project (Symfony, Laravel, or no framework at all) without rewriting anything under app/ or src/. Keep your Phel code in its own directory, export typed PHP wrappers, and call them like any other class.
PHP Coming from PHP? ›
Phel installs as a Composer package and compiles to plain PHP. Your framework never knows it is calling Lisp: it sees ordinary classes and methods.
Core idea#
- Keep Phel sources under
phel/. - Mark public functions with
{:export true}. - Create one main namespace (
app.main) that:requires every feature namespace. Loading it registers all exported functions at once. - Export PHP wrappers under your framework's
App\PSR-4 root viaphel export. - In production, run
phel buildat deploy andrequire 'build/app/main.php'at boot; in development,\Phel::run($root, 'app.main')compiles on first call. One load guard picks the right path.
Namespaces need at least two segments (shop.pricing, not pricing); a single-segment namespace exports invalid PHP.
There are two ways to call Phel from PHP:
| Flavor | How | When |
|---|---|---|
| Exported wrappers | {:export true} + vendor/bin/phel export to a typed PHP class | Production, IDE autocomplete |
| Dynamic lookup | \Phel::getDefinition($ns, $name)(...) | Scripts, prototyping |
And two load modes, behind the same provider/kernel hook:
| Mode | What | Per-request cost |
|---|---|---|
| Prod (AOT) | require 'build/app/main.php', precompiled | Zero compile, one require |
| Dev (JIT) | \Phel::run($root, 'app.main') | Gacela bootstrap + compile on first call |
Install with:
composer require phel-lang/phel-lang
Build the artifacts on deploy by letting Composer run both tools:
"scripts": {
"post-install-cmd": ["./vendor/bin/phel export", "./vendor/bin/phel build"],
"post-update-cmd": ["./vendor/bin/phel export", "./vendor/bin/phel build"]
}The main namespace pattern#
Layout:
phel/
├── app/main.phel ; main namespace, lists every feature ns
├── shop/pricing.phel
├── reports/daily.phel
└── auth/tokens.phel
phel/app/main.phel requires every feature namespace:
(ns app.main
(:require shop.pricing)
(:require reports.daily)
(:require auth.tokens))
Loading app.main (via require or \Phel::run()) registers every exported function across all three namespaces. The build walks :requires transitively, so build/app/main.php require_onces every dependency. Any controller can then call any wrapper:
App\PhelGenerated\Shop\Pricing::applyDiscount(...)
App\PhelGenerated\Reports\Daily::summary(...)
App\PhelGenerated\Auth\Tokens::makeToken(...)
To add a feature: write the .phel file, add one :require in app/main.phel, and rerun phel export + phel build.
Laravel#
phel-config.php:
<?php
use Phel\Config\PhelConfig;
return PhelConfig::forProject()
->withSrcDirs(['phel'])
->withTestDirs(['tests-phel'])
->withBuildDestDir('build')
->withExportFromDirectories(['phel'])
->withExportNamespacePrefix('App\\PhelGenerated')
->withExportTargetDirectory(__DIR__ . '/app/PhelGenerated');
A service provider runs the load guard once, with Laravel's base_path() as the root, so all wrappers are ready:
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
final class PhelServiceProvider extends ServiceProvider
{
private static bool $loaded = false;
public function boot(): void
{
if (self::$loaded) {
return;
}
$built = base_path('build/app/main.php');
if (is_file($built)) {
require $built;
} else {
\Phel::run(base_path(), 'app.main');
}
self::$loaded = true;
}
}
A controller calls the wrappers directly:
use App\PhelGenerated\Shop\Pricing;
use App\PhelGenerated\Reports\Daily;
final class CheckoutController
{
public function __invoke(Request $request): JsonResponse
{
$total = Pricing::applyDiscount(
(float) $request->input('price'),
(float) $request->input('percent'),
);
return response()->json([
'total' => $total,
'report' => Daily::summary((int) $request->input('day')),
]);
}
}Symfony#
phel-config.php (only the export target differs from Laravel):
<?php
use Phel\Config\PhelConfig;
return PhelConfig::forProject()
->withSrcDirs(['phel'])
->withTestDirs(['tests/phel'])
->withBuildDestDir('build')
->withExportFromDirectories(['phel'])
->withExportNamespacePrefix('App\\PhelGenerated')
->withExportTargetDirectory(__DIR__ . '/src/PhelGenerated');
The default App\ → src/ PSR-4 mapping covers App\PhelGenerated\.
Hook the same load guard into the kernel boot(), with getProjectDir() as the root:
private static bool $phelLoaded = false;
public function boot(): void
{
parent::boot();
if (self::$phelLoaded) {
return;
}
$built = $this->getProjectDir() . '/build/app/main.php';
if (is_file($built)) {
require $built;
} else {
\Phel::run($this->getProjectDir(), 'app.main');
}
self::$phelLoaded = true;
}
Controllers then use any wrapper (App\PhelGenerated\Reports\Daily, and so on), all registered by the main load.
Framework-less / existing src/#
phel-config.php:
<?php
use Phel\Config\PhelConfig;
return PhelConfig::forProject(mainNamespace: 'app.main')
->withSrcDirs(['phel'])
->withTestDirs(['tests/phel'])
->withBuildDestDir('build');
Entry script, applying the load guard with __DIR__ as the root:
<?php
require __DIR__ . '/vendor/autoload.php';
$built = __DIR__ . '/build/app/main.php';
if (is_file($built)) {
require $built;
} else {
\Phel::run(__DIR__, 'app.main');
}
// Call anything
$greet = \Phel::getDefinition('app.main', 'greet');
echo $greet('World') . "\n";Persistence: maps, not entities#
Phel models data as immutable values, not mutable identity-tracked objects. An ORM entity (Doctrine, Eloquent) is the opposite: a mutable object the framework hydrates and dirty-tracks. Making a Phel struct be an ORM entity fights the language. The functional path keeps rows as plain maps and pushes the database write to the edge.
Two small libraries cover it:
- phel-sql: HoneySQL-style. Map in,
[sql params]out. No driver. - phel-pdo: runs
[sql params], returns rows as maps.
(ns shop.catalog
(:require phel.sql :as sql)
(:require phel.pdo :as pdo))
(defn find-product [conn id]
(let [[query params] (sql/format {:select [:id :name :price]
:from [:products]
:where [:= :id id]})]
(-> (pdo/prepare conn query)
(pdo/execute params)
(pdo/fetch)))) ; => {:id 1 :name "Keyboard" :price 49.9}
The discount stays pure: no DB, no mutation, just a value in and a value out.
(defn apply-discount [product pct]
(update product :price (fn [p] (* p (- 1 pct)))))
(apply-discount {:id 1 :name "Keyboard" :price 49.9} 0.1)Reuse the framework connection#
Don't open a second connection. phel-sql is driver-agnostic, so its [sql params] feeds straight into the connection your framework already configured, for example Doctrine DBAL in Symfony:
// Symfony service, $conn injected (Doctrine\DBAL\Connection)
[$sql, $params] = Catalog::buildProductQuery($id); // exported Phel fn calling sql/format
$rows = $conn->executeQuery($sql, $params)->fetchAllAssociative();
phel-pdo can also wrap an existing PDO handle so its map-returning helpers run on the host's pooled connection.
When you really need the object#
Some host APIs demand a concrete instance (a typed DTO, a value object a library type-hints). Bridge at the boundary instead of modeling your domain as objects: hydrate builds an instance from a map without running its constructor (the ORM/serializer pattern), and bean reads a public-property object back into a keyword-keyed map. Keep maps as the working representation; reach for objects only at the edge where a typed instance is unavoidable.
(def dto (hydrate "App\\Dto\\Product" {:id 1 :name "Keyboard" :price 49.9}))
(bean dto) ; => {:id 1 :name "Keyboard" :price 49.9}
See Map to typed object and back in the PHP interop reference for the full semantics.
Typed PHP from Phel definitions#
When the generated PHP must satisfy a framework's type expectations, opt-in metadata enriches it. Untagged forms are unchanged.
| Metadata | On | Emits |
|---|---|---|
^{:tag T} | struct field, interface param/return | typed signature; (a b) = union a|b, [a b] = intersection a&b |
^{:php/attr [...]} | struct/interface name, field, method, param, exported defn | PHP 8 #[Attr] |
^:php/override | struct/enum/interface method | #[\Override] (PHP 8.3) |
:php/const block | definterface | typed class constants (PHP 8.3) |
^{:php/doc "..."} | struct/interface name, field, method | PHPDoc block (phpstan/psalm) |
^{:php/json true} / ^{:php/stringable true} | struct name | implements \JsonSerializable / \Stringable |
^:php/readonly | struct name | readonly typed properties |
A struct annotated as a Doctrine entity:
(defstruct ^{:php/attr [:ORM/Entity] :php/json true} product
[^{:tag int :php/attr [:ORM/Id]} id
^{:tag string} name])
Every struct already implements \Countable, \ArrayAccess, and \IteratorAggregate (no opt-in needed), so PHP code can count($s) and read fields by a plain string offset ($s['name']) as well as a keyword. Structs are immutable, so writing an offset throws.
Two related forms help framework integration:
(defenum Status :active "active" :inactive "inactive"): a native PHP backed enum (Doctrine/Symfony columns) plus aStatus?predicate. An enum can implement interfaces and carry methods, reusingdefstruct's inline-impl machinery.(defexception NotFound \RuntimeException): an exception extending a chosen parent, so frameworkcatchblocks match by type.
For the full semantics of typed emission, native enums, and exceptions, see Native enums and exceptions and the rest of the PHP Interop reference.
Controllers, transactions, migrations#
- Routes/commands: an exported
defncan carry^{:php/attr [:Symfony.Component.Routing.Attribute/Route "/products/{id}"]}, sophel exportemits the#[Route]on the generated wrapper: no hand-written controller shim. - Transactions: wrap writes with phel-pdo's transaction helpers; keep the pure work outside the transaction and only the effect inside.
- Migrations: stay in PHP-land: reuse
doctrine/migrationsor your framework's tool. Phel does not need its own.
Notes#
- Namespace path matches directory:
phel/shop/pricing.phelmaps to(ns shop.pricing). - Hyphens become camelCase:
(ns my-lib.core)maps toApp\PhelGenerated\MyLib\Core;apply-discounttoapplyDiscount. - Prod vs dev load, the run-once rule, and committing
build/: see Loading Phel: prod vs dev. - Load Phel in Laravel's
boot(), neverregister()or any per-request hot path. withBuildDestDir()is relative to the project root.- Add
vendor/bin/phel testto CI alongsidephpunit.
Next steps#
- PHP Interop - call PHP, build objects, typed emission, enums, and exceptions
- Build a Web App - end-to-end Phel web app tutorial
- Debug with PHP tools - inspect compiled PHP and dump values with Symfony VarDumper
- phel-sql and phel-pdo - data-driven SQL + PDO wrapper