Skip to content

Refactor FFI boundary #221

Closed
Closed

Description

@0x000000000000000000001

Is your change request related to a problem? Please describe.
When writing FFI bindings for PHP (such as in phpurs-aff), there's currently a pattern inherited from the JavaScript ecosystem that consists of passing a PureScript Record (like ffiUtil) to FFI functions to provide access to PureScript constructors or utility functions.

It's a bit frustrating to have to guess how the phpurs compiler translates these Records internally. In JavaScript, this works flawlessly because Records strictly compile to native JS Objects. However, in PHP, this creates a severe Impedance Mismatch and tight coupling with the compiler.

Currently, the PHP FFI code (e.g., in Effect/Aff.php) is forced to hardcode internal compiler behaviors:

  1. Data Structure: The FFI assumes the Record is compiled as a stdClass object, requiring property access syntax ($ffiUtil->...) rather than an associative array ($ffiUtil['...']).
  2. Name Mangling: Because PHP properties are case-sensitive and the phpurs compiler applies a safeName mangling algorithm universally, a field like isLeft is compiled to ismajLeft. The FFI is forced to hardcode this compiler-specific mangling: ($ffiUtil->ismajLeft)($res).
  3. Closure Invocation: PHP requires wrapping property closures in parentheses ($obj->func)() which is an awkward syntax to maintain.

If the compiler changes its internal representation of Records or its mangling algorithm tomorrow, the entire FFI layer will silently break.

Describe the solution you'd like
To make the PHP FFI truly robust and agnostic to the compiler's internal choices, we should avoid passing Records across the FFI boundary entirely. Instead, we should flatten the dependencies using Fn.FnX from Data.Function.Uncurried.

Current approach (Fragile & Coupled):

foreign import _makeAff :: forall a. Fn.Fn2 FFIUtil ((Either Error a -> Effect Unit) -> Effect Canceler) (Aff a)
$_makeAff = function($ffiUtil, $k) {
    // Highly coupled to phpurs internals: we also could have $ffiUtil['isLeft']
    // We have been forced to guess what the compiler does!
    if (($ffiUtil->isLeft)($res)) { ... }
} 

Proposed approach (Robust & Decoupled):

foreign import _makeAff :: forall a. Fn.Fn6 
  (Either a b -> Boolean)  -- isLeft
  (Either a b -> a)        -- fromLeft
  (Either a b -> b)        -- fromRight
  (a -> Either a b)        -- left
  (b -> Either a b)        -- right
  ((Either Error a -> Effect Unit) -> Effect Canceler) -- k
  (Aff a)
$_makeAff = function($isLeft, $fromLeft, $fromRight, $left, $right, $k) {
    // 100% decoupled, clean PHP syntax
    if ($isLeft($res)) {
        $exception = $fromLeft($res);
    }
}

By injecting the exact callbacks as individual arguments, the FFI no longer needs to know how the backend compiles Records, arrays, or how it mangles case-sensitive strings.

Additional context This architectural change is essential for ensuring that FFI bindings remain resilient across any potential future upgrades or optimizations within the phpurs compiler core. It sets a standard best practice for all PHP-targeted PureScript repositories (avoiding stdClass property access in FFI entirely).

Thank you! And do not hesitate to ask for a PR.

Activity

  1. natefaubion commented on Jul 7, 2026

    @natefaubion
    Collaborator

    I would wager Aff is not the only offender here (have fun with purescript-record's FFI). Part of PureScript's compilation strategy has always been that records are FFI stable, for better or worse. We've only ever recommended against passing data constructors across FFI boundaries. FWIW, in general, I agree with you, and I think PureScript's general laxness (both compiler and ecosystem) around records is a problem for alternative backends. But I'd wager that's something difficult to come back from collectively.

    The usual approach here is to fork the library into a backend-specific package-set.

  2. 0x000000000000000000001 commented on Jul 7, 2026

    @0x000000000000000000001
    Author

    Okay, I understand. I'll do that. Thanks for the quick reply.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions