Skip to main content

Framework Integration

On this page

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#

  1. Keep Phel sources under phel/.
  2. Mark public functions with {:export true}.
  3. Create one main namespace (app.main) that :requires every feature namespace. Loading it registers all exported functions at once.
  4. Export PHP wrappers under your framework's App\ PSR-4 root via phel export.
  5. In production, run phel build at deploy and require '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:

FlavorHowWhen
Exported wrappers{:export true} + vendor/bin/phel export to a typed PHP classProduction, IDE autocomplete
Dynamic lookup\Phel::getDefinition($ns, $name)(...)Scripts, prototyping

And two load modes, behind the same provider/kernel hook:

ModeWhatPer-request cost
Prod (AOT)require 'build/app/main.php', precompiledZero 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.

MetadataOnEmits
^{:tag T}struct field, interface param/returntyped signature; (a b) = union a|b, [a b] = intersection a&b
^{:php/attr [...]}struct/interface name, field, method, param, exported defnPHP 8 #[Attr]
^:php/overridestruct/enum/interface method#[\Override] (PHP 8.3)
:php/const blockdefinterfacetyped class constants (PHP 8.3)
^{:php/doc "..."}struct/interface name, field, methodPHPDoc block (phpstan/psalm)
^{:php/json true} / ^{:php/stringable true}struct nameimplements \JsonSerializable / \Stringable
^:php/readonlystruct namereadonly 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 a Status? predicate. An enum can implement interfaces and carry methods, reusing defstruct's inline-impl machinery.
  • (defexception NotFound \RuntimeException): an exception extending a chosen parent, so framework catch blocks 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 defn can carry ^{:php/attr [:Symfony.Component.Routing.Attribute/Route "/products/{id}"]}, so phel export emits 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/migrations or your framework's tool. Phel does not need its own.

Notes#

  • Namespace path matches directory: phel/shop/pricing.phel maps to (ns shop.pricing).
  • Hyphens become camelCase: (ns my-lib.core) maps to App\PhelGenerated\MyLib\Core; apply-discount to applyDiscount.
  • Prod vs dev load, the run-once rule, and committing build/: see Loading Phel: prod vs dev.
  • Load Phel in Laravel's boot(), never register() or any per-request hot path.
  • withBuildDestDir() is relative to the project root.
  • Add vendor/bin/phel test to CI alongside phpunit.

Next steps#