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
Requires
- php: >=8.0
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 likemapPathFromClass(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).
- By default, the original class is preserved under an automatic alias (
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):
- Per-mapping explicit alias:
aliasAs('Domain\\Report__Real') - Per-mapping resolver:
aliasResolver(fn(string $class) => "{$class}__Real") - Global resolver:
aliasNamingResolver(fn(string $class) => "{$class}__Real") - Automatic:
ClassName__<short-hash>
- Per-mapping explicit alias:
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 multipleforClass(...)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
-
Provider decides semantics
- Extends target ⇒ structural override (public class extends the aliased original).
- Doesn’t extend ⇒ API override (merge/replace members; hierarchy unchanged).
-
Original preservation
- Structural override: original is always preserved (aliased).
- API override: original is preserved by default; you may call
discardOriginal().
-
Aliasing
- When needed, alias is automatic (
ClassName__<short-hash>) unless you set a per-mapping or global resolver/name.
- When needed, alias is automatic (
-
Everything is
mapPath(...)internallymapClass(FQN)is sugar that resolves a file and then opens a single class target within the file.
-
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.
- Each call resolves all newly added mappings (since the previous
License
MIT