nekoos/php-ast-overlay

AOT PHP AST overlay: rewrite classes/files before load; serve stubs via file stream wrapper. No runtime monkey patching.

Installs: 0

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Watchers: 0

Forks: 0

pkg:composer/nekoos/php-ast-overlay

dev-master 2025-10-12 00:30 UTC

This package is auto-updated.

Last update: 2025-10-12 00:30:31 UTC


README

AOT (ahead-of-time) PHP AST overlay: rewrite classes/files before load and serve stubs through a file stream wrapper. No runtime monkey-patching.

If you need runtime redefinitions, see antecedent/patchwork.

Install

composer require --dev nekoos/php-ast-overlay

Quick start — map classes or files (helpers first)

<?php
declare(strict_types=1);

use Nekoos\AstOverlay\Kernel;
use Nekoos\AstOverlay\Overlay;

require __DIR__ . '/vendor/autoload.php';

Overlay::factory(new Kernel())
  ->enableLoader()            // install the file:// stream wrapper
  ->lazyByDefault()           // default AOT stub generation mode

  // Default path resolvers you likely want in most projects
  ->withComposerAutoloader(require __DIR__ . '/vendor/autoload.php')
  ->withPsr4([
      'App\\'    => __DIR__ . '/src/App',
      'Domain\\' => __DIR__ . '/src/Domain',
  ])
  ->withPsr0([
      'Legacy_'  => __DIR__ . '/legacy/classes',
  ])
  ->withClassMap([
      'Legacy\\Foo' => __DIR__ . '/legacy/foo.inc.php',
  ])

  // --- Minimal class override (sugar) ---
  ->mapClass(Domain\UserProfile::class)
      ->overrideFrom(Overlays\UserProfileTweaks::class)
      ->done()

  // --- Map a file directly (for functions/constants) ---
  ->mapPath(__DIR__ . '/src/Support/helpers.php')
      ->replaceFunctions(['app_now' => '\\Tests\\Shim\\fake_now'])
      ->replaceConstants(['APP_ENV' => 'test'], mode: 'both', scope: 'auto')
      ->done()

  ->register();

Note: mapClass(FQN) is sugar. Internally it resolves to a file and behaves like mapPathFromClass(FQN)->forClass(FQN).

Why this library?

  • AOT, not monkey-patching: transforms happen before PHP loads the file, producing stable stubs (OPcache-friendly).
  • Predictable: fail fast on unresolved mappings at register().
  • Composable: target classes and file scope (functions/constants), handle multiple classes per file, and apply precise overrides.

Core concepts

  • Provider (override): the class that supplies members (and potentially structure) to the target class.

  • Structural vs API override is decided by the provider:

    • If the provider extends the target, we perform a structural override (promotion with inheritance).
    • If the provider does not extend, we perform an API override (merge/replace members; hierarchy unchanged).
  • Original class preservation / aliasing:

    • By default, the original class is preserved under an automatic alias (ClassName__<short-hash>).
    • If a provider extends the target, preserving the original (aliased) is always required.
    • If a provider does not extend, you may opt to destroy the original with discardOriginal() (see rules below).

Clear structural examples

A) API-only override (provider does not extend the target)

Original

// src/Domain/UserProfile.php
namespace Domain;

class UserProfile
{
    protected string $displayName = 'Guest';
    protected int $loginCount = 0;

    public function greet(): string
    {
        return "Hello, {$this->displayName} (#{$this->loginCount})";
    }

    public function increment(): void
    {
        $this->loginCount++;
    }
}

Provider (no extends; only members to replace)

// src/Overlays/UserProfileTweaks.php
namespace Overlays;

class UserProfileTweaks
{
    protected string $displayName = 'Tester';

    public function greet(): string
    {
        return "[OVERRIDE] Hi, {$this->displayName}! (count={$this->loginCount})";
    }
}

Mapping

Overlay::factory(new DefaultKernel())
  ->enableLoader()
  ->lazyByDefault()
  ->withPsr4(['Domain\\' => __DIR__.'/src/Domain', 'Overlays\\' => __DIR__.'/src/Overlays'])

  ->mapClass(Domain\UserProfile::class)
      ->overrideFrom(Overlays\UserProfileTweaks::class)
      ->done()

  ->register();

Result (conceptual)

namespace Domain;

class UserProfile__a1b2c3
{
    protected string $displayName = 'Guest';
    protected int $loginCount = 0;
    public function greet(): string { return "Hello, {$this->displayName} (#{$this->loginCount})"; }
    public function increment(): void { $this->loginCount++; }
}

class UserProfile
{
    protected string $displayName = 'Tester';
    protected int $loginCount = 0;
    public function greet(): string { return "[OVERRIDE] Hi, {$this->displayName}! (count={$this->loginCount})"; }
    public function increment(): void { $this->loginCount++; }
}

Default behavior keeps the original under an alias (automatic name). If you really want a destructive override here, add ->discardOriginal() in the mapping.

B) Structural override (provider extends target)

Original

// src/Domain/Report.php
namespace Domain;

class Report
{
    protected array $data = ['status' => 'ok', 'count' => 1];

    public function toArray(): array
    {
        return ['ORIG' => $this->data];
    }

    public function touch(): void
    {
        $this->data['count']++;
    }
}

Provider (extends the target)

// src/Overlays/ReportOverride.php
namespace Overlays;

use Domain\Report;

class ReportOverride extends Report
{
    protected array $data = ['status' => 'override', 'count' => 10, 'flag' => true];

    public function toArray(): array
    {
        if ($this->data['flag']) {
            return ['OVERRIDE' => $this->data, 'ts' => \time()];
        }
        return parent::toArray();
    }
}

Mapping

Overlay::factory(new DefaultKernel())
  ->enableLoader()
  ->lazyByDefault()
  ->withPsr4(['Domain\\' => __DIR__.'/src/Domain', 'Overlays\\' => __DIR__.'/src/Overlays'])

  ->mapClass(Domain\Report::class)
      ->overrideFrom(Overlays\ReportOverride::class) // provider EXTENDS target
      ->done()

  ->register();

Result (conceptual)

namespace Domain;

class Report__9f8e7d
{
    protected array $data = ['status' => 'ok', 'count' => 1];
    public function toArray(): array { return ['ORIG' => $this->data]; }
    public function touch(): void { $this->data['count']++; }
}

class Report extends Report__9f8e7d
{
    protected array $data = ['status' => 'override', 'count' => 10, 'flag' => true];
    public function toArray(): array {
        if ($this->data['flag']) {
            return ['OVERRIDE' => $this->data, 'ts' => \time()];
        }
        return parent::toArray();
    }
    public function touch(): void { $this->data['count']++; }
}

When the provider extends the target, the original must be preserved (aliased) and the public class extends that alias.

Aliasing rules & customization

  • Aliasing is automatic whenever the original must be preserved:

    • Always for structural overrides (provider extends target).
    • By default, for API overrides; you may opt out with discardOriginal() (only allowed when provider does not extend).
  • Alias name precedence (when aliasing is in effect):

    1. Per-mapping explicit alias: aliasAs('Domain\\Report__Real')
    2. Per-mapping resolver: aliasResolver(fn(string $class) => "{$class}__Real")
    3. Global resolver: aliasNamingResolver(fn(string $class) => "{$class}__Real")
    4. Automatic: ClassName__<short-hash>

Global alias resolver (optional):

Overlay::factory(new DefaultKernel())
  ->enableLoader()
  ->aliasNamingResolver(fn(string $class) => "{$class}__Real")
  ->register();

Per-mapping alias (class sugar):

->mapClass(Domain\Report::class)
    ->overrideFrom(Overlays\ReportOverride::class)
    ->aliasAs('Domain\\Report__Real')
    ->done();

Per-mapping alias (file/target builder):

->mapPath(__DIR__.'/src/reporter.php')
    ->forClass('Domain\\Report')
        ->overrideFrom('Overlays\\ReportOverride')
        ->aliasAs('Domain\\Report__Real')
        ->doneTarget()
    ->done();

Multiple classes (and file-scope changes) in the same file

PHP files often contain several classes plus functions/constants. Handle all of them in one go with the path builder:

Overlay::factory(new DefaultKernel())
  ->enableLoader()
  ->lazyByDefault()

  ->mapPath(__DIR__ . '/src/reporter.php')

      // Class target #1
      ->forClass('Domain\\Report')
          ->overrideFrom('Overlays\\ReportOverride') // provider may extend or not
          ->aliasAs('Domain\\Report__Real')          // per-target alias if needed
          ->doneTarget()

      // Class target #2
      ->forClass('Domain\\Summary')
          ->overrideFrom('Overlays\\SummaryTweaks')  // provider does NOT extend
          // ->discardOriginal()                     // allowed here if you desire destructive override
          ->doneTarget()

      // File-scope replacements (affect this file)
      ->replaceFunctions([
          'report_now' => '\\Tests\\Shim\\fake_now',
          'report_id'  => '\\Tests\\Shim\\fixed_id',
      ])
      ->replaceConstants([
          'REPORT_ENABLED' => true,
          'REPORT_LIMIT'   => 250,
      ], mode: 'both', scope: 'auto')

      ->done()

  ->register();

doneTarget() vs done()

  • mapClass(...)->done() closes a simple class mapping (one target).
  • mapPath(...)->forClass(...)->doneTarget() closes one class target within the file builder. You may open multiple forClass(...) blocks before calling the file-level ->done().

Conceptual equivalence:

// Sugar: one class
mapClass('Domain\\Report')
  ->overrideFrom('Overlays\\ReportOverride')
  ->done();

// Explicit: same effect, plus room for more targets and file-scope changes
mapPathFromClass('Domain\\Report')      // resolves the file that defines the class
  ->forClass('Domain\\Report')
      ->overrideFrom('Overlays\\ReportOverride')
      ->doneTarget()
  ->done();

Use mapPathFromClass(FQN) if you want to add file-scope changes (functions/constants) to the same file after mapping the class.

Rules you can rely on

  1. Provider decides semantics

    • Extends target ⇒ structural override (public class extends the aliased original).
    • Doesn’t extend ⇒ API override (merge/replace members; hierarchy unchanged).
  2. Original preservation

    • Structural override: original is always preserved (aliased).
    • API override: original is preserved by default; you may call discardOriginal().
  3. Aliasing

    • When needed, alias is automatic (ClassName__<short-hash>) unless you set a per-mapping or global resolver/name.
  4. Everything is mapPath(...) internally

    • mapClass(FQN) is sugar that resolves a file and then opens a single class target within the file.
  5. register() is strict, incremental, repeatable

    • Each call resolves all newly added mappings (since the previous register()), or throws an aggregated error.
    • The first call installs the stream wrapper; later calls update the path map and run eager stubs for new mappings.

License

MIT