diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..8a5f881
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,70 @@
+name: Bug report
+description: Report a reproducible problem
+title: "[Bug]: "
+labels:
+ - bug
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for reporting a bug. Please include enough detail to reproduce it.
+ - type: textarea
+ id: summary
+ attributes:
+ label: Summary
+ description: What is wrong?
+ placeholder: Clear and short description of the bug.
+ validations:
+ required: true
+ - type: textarea
+ id: reproduce
+ attributes:
+ label: Steps to reproduce
+ description: Share exact commands, config, and steps.
+ placeholder: |
+ 1. Run `composer ic:tests`
+ 2. ...
+ 3. Observe ...
+ validations:
+ required: true
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected behavior
+ placeholder: What did you expect to happen?
+ validations:
+ required: true
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual behavior
+ placeholder: What happened instead? Include full error output if possible.
+ validations:
+ required: true
+ - type: input
+ id: php_version
+ attributes:
+ label: PHP version
+ placeholder: "e.g. 8.3.8"
+ validations:
+ required: true
+ - type: input
+ id: composer_version
+ attributes:
+ label: Composer version
+ placeholder: "e.g. 2.9.2"
+ validations:
+ required: true
+ - type: textarea
+ id: environment
+ attributes:
+ label: Environment details
+ description: OS, CI provider, shell, and anything else relevant.
+ placeholder: Ubuntu 24.04, GitHub Actions, bash...
+ validations:
+ required: true
+ - type: textarea
+ id: additional
+ attributes:
+ label: Additional context
+ description: Links, screenshots, logs, or related issues.
diff --git a/.github/ISSUE_TEMPLATE/ci_failure.yml b/.github/ISSUE_TEMPLATE/ci_failure.yml
new file mode 100644
index 0000000..3dcbac9
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/ci_failure.yml
@@ -0,0 +1,48 @@
+name: CI failure
+description: Report a reproducible CI or workflow failure
+title: "[CI]: "
+labels:
+ - ci
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Use this form when CI fails unexpectedly and can be reproduced.
+ - type: input
+ id: workflow
+ attributes:
+ label: Workflow/job name
+ placeholder: security-standards / phpforge
+ validations:
+ required: true
+ - type: input
+ id: run_url
+ attributes:
+ label: Failing run URL
+ placeholder: https://github.com/OWNER/REPOSITORY/actions/runs/...
+ validations:
+ required: true
+ - type: textarea
+ id: command
+ attributes:
+ label: Failing command
+ description: Exact command or step that failed.
+ placeholder: composer ic:ci
+ validations:
+ required: true
+ - type: textarea
+ id: logs
+ attributes:
+ label: Error output
+ description: Paste the relevant error section.
+ render: shell
+ validations:
+ required: true
+ - type: textarea
+ id: local_check
+ attributes:
+ label: Local reproduction
+ description: Can you reproduce locally? If yes, include steps.
+ placeholder: Yes/No + details
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..3ba13e0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1 @@
+blank_issues_enabled: false
diff --git a/.github/ISSUE_TEMPLATE/docs_improvement.yml b/.github/ISSUE_TEMPLATE/docs_improvement.yml
new file mode 100644
index 0000000..80b9607
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/docs_improvement.yml
@@ -0,0 +1,34 @@
+name: Docs improvement
+description: Report missing, unclear, or incorrect documentation
+title: "[Docs]: "
+labels:
+ - documentation
+body:
+ - type: textarea
+ id: location
+ attributes:
+ label: Documentation location
+ description: File path or URL.
+ placeholder: README.md section "Quick Start"
+ validations:
+ required: true
+ - type: textarea
+ id: issue
+ attributes:
+ label: What is unclear or incorrect?
+ placeholder: This section says...
+ validations:
+ required: true
+ - type: textarea
+ id: suggestion
+ attributes:
+ label: Suggested improvement
+ description: Propose revised wording, structure, or examples.
+ placeholder: It would be clearer if...
+ validations:
+ required: true
+ - type: textarea
+ id: additional
+ attributes:
+ label: Additional context
+ description: Related links, screenshots, or prior discussions.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..cc29614
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,44 @@
+name: Feature request
+description: Suggest an improvement or new capability
+title: "[Feature]: "
+labels:
+ - enhancement
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for the idea. Please describe the use case first, then the proposed solution.
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem or use case
+ description: What limitation are you hitting?
+ placeholder: I need to...
+ validations:
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed solution
+ description: What should happen?
+ placeholder: Add a command/config/workflow option that...
+ validations:
+ required: true
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered
+ description: Any workaround or alternative approach you evaluated.
+ - type: textarea
+ id: impact
+ attributes:
+ label: Expected impact
+ description: Who benefits and what changes for users/CI?
+ placeholder: This would improve...
+ validations:
+ required: true
+ - type: textarea
+ id: additional
+ attributes:
+ label: Additional context
+ description: Related issues, links, examples, or prior art.
diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml
new file mode 100644
index 0000000..2ca776f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/question.yml
@@ -0,0 +1,40 @@
+name: Question
+description: Ask a usage or integration question
+title: "[Question]: "
+labels:
+ - question
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Use this form for usage questions. For confirmed defects, use the bug report form.
+ - type: textarea
+ id: context
+ attributes:
+ label: What are you trying to do?
+ description: Describe your goal and expected outcome.
+ placeholder: I want to...
+ validations:
+ required: true
+ - type: textarea
+ id: attempted
+ attributes:
+ label: What have you tried?
+ description: Include commands, config snippets, or links you already checked.
+ placeholder: I tried...
+ validations:
+ required: true
+ - type: textarea
+ id: output
+ attributes:
+ label: Current output or behavior
+ description: Include relevant command output, logs, or errors.
+ render: shell
+ - type: textarea
+ id: environment
+ attributes:
+ label: Environment details
+ description: PHP version, Composer version, OS, CI provider (if relevant).
+ placeholder: PHP 8.3, Composer 2.9, Ubuntu 24.04...
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/regression_report.yml b/.github/ISSUE_TEMPLATE/regression_report.yml
new file mode 100644
index 0000000..36392bc
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/regression_report.yml
@@ -0,0 +1,51 @@
+name: Regression report
+description: Report behavior that previously worked but now fails
+title: "[Regression]: "
+labels:
+ - regression
+ - bug
+body:
+ - type: textarea
+ id: summary
+ attributes:
+ label: Regression summary
+ placeholder: This worked before, but now...
+ validations:
+ required: true
+ - type: input
+ id: last_known_good
+ attributes:
+ label: Last known working version/commit
+ placeholder: v1.2.3 or abc1234
+ validations:
+ required: true
+ - type: input
+ id: first_bad
+ attributes:
+ label: First broken version/commit
+ placeholder: v1.2.4 or def5678
+ - type: textarea
+ id: reproduce
+ attributes:
+ label: Steps to reproduce
+ placeholder: |
+ 1. ...
+ 2. ...
+ 3. ...
+ validations:
+ required: true
+ - type: textarea
+ id: expected_actual
+ attributes:
+ label: Expected vs actual behavior
+ placeholder: Expected ..., but got ...
+ validations:
+ required: true
+ - type: textarea
+ id: environment
+ attributes:
+ label: Environment details
+ description: PHP version, Composer version, OS, CI provider (if relevant).
+ placeholder: PHP 8.3, Composer 2.9, Ubuntu 24.04...
+ validations:
+ required: true
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..59ae734
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,33 @@
+## Summary
+
+Describe what changed and why.
+
+## Related Issues
+
+Link issues with `Closes #...` or `Relates #...`.
+
+## Type of Change
+
+- [ ] Bug fix
+- [ ] New feature
+- [ ] Refactor
+- [ ] Documentation update
+- [ ] CI or tooling update
+- [ ] Other (describe in summary)
+
+## Validation
+
+List the commands you ran and their result.
+
+```bash
+composer ic:tests
+```
+
+If full suite was not run, explain why and list focused checks.
+
+## Checklist
+
+- [ ] I followed `CONTRIBUTING.md`.
+- [ ] I added or updated tests for behavior changes.
+- [ ] I updated docs/config/examples when needed.
+- [ ] I confirmed no security-sensitive data is exposed.
diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml
index 68e846a..df17b31 100644
--- a/.github/workflows/security-standards.yml
+++ b/.github/workflows/security-standards.yml
@@ -19,11 +19,22 @@ jobs:
php_versions: '["8.3","8.4","8.5"]'
dependency_versions: '["prefer-lowest","prefer-stable"]'
php_extensions: "mbstring"
- coverage: "xdebug"
composer_flags: ""
phpstan_memory_limit: "1G"
psalm_threads: "1"
run_analysis: true
run_svg_report: true
+ fail_on_skipped_tests: false
+ enable_redis_service: false
+ enable_valkey_service: false
+ enable_memcached_service: false
+ enable_postgres_service: false
+ enable_mysql_service: false
+ enable_scylladb_service: false
+ enable_elasticsearch_service: false
+ enable_mongodb_service: false
+ service_db_name: "phpforge"
+ service_db_user: "phpforge"
+ service_db_password: "phpforge"
artifact_retention_days: 61
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 18f6d18..9c2638f 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -1,143 +1,50 @@
-# Contributor Covenant Code of Conduct
+# Code of Conduct
-## Our Pledge
+## Our Commitment
-We as members, contributors, and leaders pledge to make participation in our
-community a harassment-free experience for everyone, regardless of age, body
-size, visible or invisible disability, ethnicity, sex characteristics, gender
-identity and expression, level of experience, education, socio-economic status,
-nationality, personal appearance, race, religion, or sexual identity
-and orientation.
+We are committed to making participation in this project a harassment-free
+experience for everyone, regardless of age, body size, disability, ethnicity,
+gender identity and expression, level of experience, nationality, personal
+appearance, race, religion or sexual identity and orientation.
-We pledge to act and interact in ways that contribute to an open, welcoming,
-diverse, inclusive, and healthy community.
+## Expected Behavior
-## Our Standards
+Examples of behavior that contributes to a positive environment:
-Examples of behavior that contributes to a positive environment for our
-community include:
+- Be respectful and constructive.
+- Assume good intent and ask clarifying questions.
+- Give and receive feedback professionally.
+- Focus on what is best for the community and project.
-- Demonstrating empathy and kindness toward other people
-- Being respectful of differing opinions, viewpoints, and experiences
-- Giving and gracefully accepting constructive feedback
-- Accepting responsibility and apologizing to those affected by our mistakes,
- and learning from the experience
-- Focusing on what is best not just for us as individuals, but for the
- overall community
+## Unacceptable Behavior
Examples of unacceptable behavior include:
-- The use of sexualized language or imagery, and sexual attention or
- advances of any kind
-- Trolling, insulting or derogatory comments, and personal or political attacks
-- Public or private harassment
-- Publishing others' private information, such as a physical or email
- address, without their explicit permission
-- Other conduct which could reasonably be considered inappropriate in a
- professional setting
+- Harassment, discrimination or personal attacks.
+- Trolling, insulting or derogatory comments.
+- Publishing private information without consent.
+- Any conduct that is inappropriate in a professional setting.
## Enforcement Responsibilities
-Community leaders are responsible for clarifying and enforcing our standards of
-acceptable behavior and will take appropriate and fair corrective action in
-response to any behavior that they deem inappropriate, threatening, offensive,
-or harmful.
-
-Community leaders have the right and responsibility to remove, edit, or reject
-comments, commits, code, wiki edits, issues, and other contributions that are
-not aligned to this Code of Conduct, and will communicate reasons for
-moderation decisions when appropriate.
+Project maintainers are responsible for clarifying and enforcing this code of
+conduct. They may remove, edit or reject comments, commits, code, issues, and
+other contributions that violate this policy.
## Scope
-This Code of Conduct applies within all community spaces, and also applies when
-an individual is officially representing the community in public spaces.
-Examples of representing our community include using an official email address,
-posting via an official social media account, or acting as an appointed
-representative at an online or offline event.
-
-## Enforcement
-
-Instances of abusive, harassing, or otherwise unacceptable behavior may be
-reported to the community leaders responsible for enforcement at:
-`infocyph@gmail.com`
-
-When reporting, include:
-
-- A description of the incident
-- Relevant links, screenshots, or logs
-- Date/time and context
-- Any immediate safety concerns
-
-All complaints will be reviewed and investigated promptly and fairly. We will
-acknowledge reports as soon as possible and keep reporters informed of high-level
-status where appropriate.
-
-All community leaders are obligated to respect the privacy and security of the
-reporter of any incident.
-
-## Enforcement Guidelines
-
-Community leaders will follow these Community Impact Guidelines in determining
-the consequences for any action they deem in violation of this Code of Conduct:
-
-### 1. Correction
-
-**Community Impact**: Use of inappropriate language or other behavior deemed
-unprofessional or unwelcome in the community.
+This code of conduct applies in all project spaces, including:
-**Consequence**: A private, written warning from community leaders, providing
-clarity around the nature of the violation and an explanation of why the
-behavior was inappropriate. A public apology may be requested.
+- Issue trackers
+- Pull requests
+- Discussions and chat related to the project
+- Any public or private communication where someone represents the project
-### 2. Warning
+## Reporting
-**Community Impact**: A violation through a single incident or series
-of actions.
+To report unacceptable behavior, contact project maintainers privately.
-**Consequence**: A warning with consequences for continued behavior. No
-interaction with the people involved, including unsolicited interaction with
-those enforcing the Code of Conduct, for a specified period of time. This
-includes avoiding interactions in community spaces as well as external channels
-like social media. Violating these terms may lead to a temporary or
-permanent ban.
-
-### 3. Temporary Ban
-
-**Community Impact**: A serious violation of community standards, including
-sustained inappropriate behavior.
-
-**Consequence**: A temporary ban from any sort of interaction or public
-communication with the community for a specified period of time. No public or
-private interaction with the people involved, including unsolicited interaction
-with those enforcing the Code of Conduct, is allowed during this period.
-Violating these terms may lead to a permanent ban.
-
-### 4. Permanent Ban
-
-**Community Impact**: Demonstrating a pattern of violation of community
-standards, including sustained inappropriate behavior, harassment of an
-individual, or aggression toward or disparagement of classes of individuals.
-
-**Consequence**: A permanent ban from any sort of public interaction within
-the community.
-
-## Appeals
-
-If you believe an enforcement decision was made in error, you may appeal by
-replying to the enforcement communication or by emailing `infocyph@gmail.com`
-with additional context. Appeals will be reviewed by maintainers not directly
-involved in the original decision whenever possible.
-
-## Attribution
-
-This Code of Conduct is adapted from the Contributor Covenant, version 2.1,
-available at:
-https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
-
-Community Impact Guidelines were inspired by Mozilla's code of conduct
-enforcement ladder:
-https://github.com/mozilla/diversity
+## Enforcement
-For answers to common questions about this code of conduct, see:
-https://www.contributor-covenant.org/faq
+Maintainers may take any action they deem appropriate, including warnings,
+temporary bans or permanent bans from community participation.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..9950065
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,68 @@
+# Contributing
+
+Thanks for contributing.
+
+## Before You Start
+
+- Review the project code of conduct.
+- For security issues, use private reporting and avoid opening a public issue.
+- Check existing issues and pull requests first to avoid duplicates.
+
+## Local Setup
+
+Requirements:
+
+- See `README.md` for current PHP and Composer requirements.
+
+Install dependencies:
+
+```bash
+composer install
+```
+
+## Development Workflow
+
+Typical contributor workflow:
+
+1. Create a branch from `main`.
+2. Make focused changes.
+3. Run quality checks locally.
+4. Open a pull request with context and verification notes.
+
+Recommended checks:
+
+```bash
+composer ic:tests
+```
+
+Useful targeted commands:
+
+```bash
+composer ic:test:syntax
+composer ic:test:code
+composer ic:test:lint
+composer ic:test:sniff
+composer ic:test:static
+composer ic:test:security
+composer ic:test:architecture
+```
+
+Auto-fix and processing helpers:
+
+```bash
+composer ic:process
+```
+
+## Pull Request Guidelines
+
+- Keep pull requests scoped to one logical change.
+- Include why the change is needed and what behavior changed.
+- Add or update tests when behavior changes.
+- Update docs when command behavior, config, or workflow behavior changes.
+- Ensure CI is green before requesting review.
+
+## Reporting Bugs and Requesting Features
+
+- Use issue templates for bugs, regressions, CI failures, documentation updates, questions, and feature requests.
+- Include reproducible steps, expected behavior, and actual behavior.
+- Share environment details (PHP version, OS, Composer version).
diff --git a/README.md b/README.md
index 4f8dcf7..be00c64 100644
--- a/README.md
+++ b/README.md
@@ -10,9 +10,9 @@
`InterMix` is a modern, lightweight PHP toolkit for developers who value class-oriented design, clean architecture, and fast execution. It combines dependency injection, serialization, macro-style extensibility, and helper utilities with minimal config and maximum control.
----
+> Global helper functions are optional in `v2`: core APIs are namespaced and helper loading is opt-in.
-## 🚀 Key Features
+## Key Features
- **Dependency Injection (DI)** — PSR-11 compliant container with:
- attribute-based injection
@@ -26,14 +26,18 @@
- **MacroMix** — Dynamically extend objects or classes with macros
- **Global Utilities** — Like `pipe()`, `retry()`, `measure()` and more
----
-
-## 📦 Installation
+## Installation
```bash
composer require infocyph/intermix
```
+Optional global helpers:
+
+```php
+require_once __DIR__ . '/vendor/infocyph/intermix/src/functions.php';
+```
+
Supported PHP versions:
| InterMix Version | PHP Version |
@@ -41,11 +45,9 @@ Supported PHP versions:
| 2.x.x and above | 8.3 or newer |
| 1.x.x | 8.0-8.2 compatible |
----
-
-## ⚡ Quick Examples
+## Quick Examples
-### 🧱 Dependency Injection
+### Dependency Injection
```php
use function Infocyph\InterMix\container;
@@ -77,12 +79,9 @@ foreach ($c->findByTag('service') as $svc) {
}
```
-See full container guide at:
-📖 [https://docs.infocyph.com/projects/intermix/di/overview.html](https://docs.infocyph.com/projects/intermix/di/overview.html)
+See full container guide at: [https://docs.infocyph.com/projects/intermix/di/overview.html](https://docs.infocyph.com/projects/intermix/di/overview.html)
----
-
-### 🧬 Dynamic Macros
+### Dynamic Macros
```php
MacroTest::mix(new class {
@@ -94,55 +93,58 @@ MacroTest::mix(new class {
echo (new MacroTest)->hello('Ali'); // Hey, Ali!
```
----
-
-### 🧠 Definition Cache (Injectable)
+### Definition Cache (Injectable)
```php
use Psr\Cache\CacheItemPoolInterface;
$pool = /* any PSR-6 pool, e.g. from infocyph/cachelayer */;
-$c->definitions()->enableDefinitionCache($pool);
+$c->definitions()->enableDefinitionCache($pool, cacheRuntimeObjects: false);
```
----
+### Compiled Resolvers
-## 📚 Documentation
+```php
+$path = __DIR__ . '/var/intermix.compiled.php';
-Full documentation available at:
+// build-time: generate compiled resolver map
+$c->compileTo($path);
-🔗 [https://docs.infocyph.com/projects/intermix/](https://docs.infocyph.com/projects/intermix/)
+// runtime: activate compiled resolver map explicitly
+$c->useCompiled($path);
-Includes:
+// optional one-step build + activate
+$c->compileTo($path, load: true);
+```
-* ✅ Getting Started & Quickstart
-* 📦 DI Container Guide (bindings, scopes, attributes, lifetimes)
-* 🧩 Modules: DI, Serializer, Remix, Fence, MacroMix
-* 🧪 Testing & Caching Tips
-* 📘 PDF/ePub formats
+### Signed Serialization
----
+```php
+$signed = \Infocyph\InterMix\Serializer\ValueSerializer::signed($_ENV['APP_KEY']);
+$token = $signed->encode(['user_id' => 1]);
+$payload = $signed->decode($token);
+```
-## ✅ Testing
+## Testing
```bash
composer install
composer test
```
----
-
-## 🤝 Contributing
-Got ideas or improvements? Join us!
+## Security
-📂 [Open issues](https://github.com/infocyph/InterMix/issues)
-📬 Submit a PR — we welcome quality contributions
+Protected by [PHPForge](https://github.com/infocyph/PHPForge) — an automated quality and security gate for PHP projects.
---
-## 🛡 License
-
-MIT Licensed — use it freely, modify it openly.
-
-🔗 [opensource.org/licenses/MIT](https://opensource.org/licenses/MIT)
+
diff --git a/SECURITY.md b/SECURITY.md
index 5a9baba..37a355e 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,55 +1,49 @@
# Security Policy
-Thank you for helping keep InterMix and its users safe.
-
## Supported Versions
-Security fixes are provided for the current major release line.
-
-| Version | Supported |
-|---------| --- |
-| `7.x` | ✅ |
-| `< 7.0` | ❌ |
+The project currently supports security updates for the latest release.
## Reporting a Vulnerability
-Please report vulnerabilities privately. Do not open a public issue.
-
-Preferred channels:
+Please report vulnerabilities privately.
-1. GitHub private vulnerability reporting:
- `Security` -> `Advisories` -> `Report a vulnerability`
-2. Email fallback: `infocyph@gmail.com`
+1. Use GitHub private vulnerability reporting for this repository (`Security` -> `Advisories` -> `Report a vulnerability`).
+2. If private reporting is unavailable, contact maintainers through a private channel.
+3. Do not open a public issue for security vulnerabilities.
Please include:
- Affected package version(s)
- PHP version and runtime environment
-- Reproduction steps or proof-of-concept
-- Impact assessment (confidentiality, integrity, availability)
-- Known mitigations or workarounds
-
-## Response Targets
-
-- Initial acknowledgment: within 3 business days
-- Triage decision: within 7 business days
-- Remediation timeline: severity and exploitability dependent
+- Reproduction steps or proof of concept
+- Impact assessment (confidentiality/integrity/availability)
+- Any known workaround
-If a report is accepted, we will prepare a patched release and publish an advisory. Reporter credit will be included unless you request to remain anonymous.
+## Response Process
-## Coordinated Disclosure
+- Initial acknowledgment: best effort, typically within a few days
+- Triage: best effort, based on maintainer availability
+- Fix and release timeline depends on severity and exploitability
-Please keep details private until a fix is released. After remediation, advisory details and release notes will be published. CVE assignment may be requested when appropriate.
+If a report is accepted, a patched release will be prepared and published. Credit will be provided unless you request otherwise.
-## Scope
+## Protected by PHPForge
-In scope:
+This project is protected by [PHPForge](https://github.com/infocyph/PHPForge), an automated quality and security tooling layer for Infocyph PHP projects.
-- Vulnerabilities in maintained code under `src/`
-- Security-impacting dependency risks in direct dependencies
+PHPForge helps keep the project reliable by running checks for:
-Out of scope:
+- Code style and standards
+- Tests and syntax validation
+- Static analysis and type safety
+- Security and taint analysis
+- Dependency vulnerability audit
+- Architecture boundary validation
+- Duplicate-code detection
+- API snapshot and comment-policy checks
+- Refactor safety checks
+- Benchmark and release-readiness checks
+- Git hooks and CI workflow protection
-- Issues affecting unsupported versions only
-- Local-only misconfiguration without a library defect
-- Non-security bugs or feature requests
+These automated gates strengthen code quality, reduce security risk and help prevent regressions before merge or release.
diff --git a/benchmarks/FenceBench.php b/benchmarks/FenceBench.php
new file mode 100644
index 0000000..34faf86
--- /dev/null
+++ b/benchmarks/FenceBench.php
@@ -0,0 +1,121 @@
+newKeyCounter));
+ }
+
+ #[BeforeMethods('setUpRequirementCreationPath')]
+ public function benchRequirementCreationPath(): void
+ {
+ FenceBenchMulti::instance(
+ 'required-' . (++$this->requiredNewKeyCounter),
+ ['extensions' => ['Core'], 'classes' => [\stdClass::class]],
+ );
+ }
+
+ #[BeforeMethods('setUpRequirementHitPath')]
+ public function benchRequirementHitPath(): void
+ {
+ FenceBenchMulti::instance(
+ 'required-hit',
+ ['extensions' => ['nonexistent_ext'], 'classes' => ['Missing\\NotFound']],
+ );
+ }
+
+ #[BeforeMethods('setUpSingleHotPath')]
+ public function benchSingleInstanceHotPath(): void
+ {
+ FenceBenchSingle::instance();
+ }
+
+ public function setUpLimitExceededPath(): void
+ {
+ FenceBenchLimit::clearInstances();
+ FenceBenchLimit::setLimit(1);
+ FenceBenchLimit::instance('seed');
+ }
+
+ public function setUpMultiExistingKeyHotPath(): void
+ {
+ FenceBenchMulti::clearInstances();
+ FenceBenchMulti::instance('existing');
+ }
+
+ public function setUpMultiNewKeyPath(): void
+ {
+ $this->newKeyCounter = 0;
+ FenceBenchMulti::clearInstances();
+ }
+
+ public function setUpRequirementCreationPath(): void
+ {
+ $this->requiredNewKeyCounter = 0;
+ FenceBenchMulti::clearInstances();
+ }
+
+ public function setUpRequirementHitPath(): void
+ {
+ FenceBenchMulti::clearInstances();
+ FenceBenchMulti::instance('required-hit');
+ }
+
+ public function setUpSingleHotPath(): void
+ {
+ FenceBenchSingle::clearInstances();
+ FenceBenchSingle::instance();
+ }
+}
+
+final class FenceBenchSingle
+{
+ use Single;
+}
+
+final class FenceBenchMulti
+{
+ use Multi;
+}
+
+final class FenceBenchLimit
+{
+ use Limit;
+}
diff --git a/composer.json b/composer.json
index e4cca9e..a69f7d9 100644
--- a/composer.json
+++ b/composer.json
@@ -1,14 +1,18 @@
{
"name": "infocyph/intermix",
- "description": "A Collection of useful PHP class functions.",
+ "description": "A lightweight PHP DI container, invoker, serializer, and utility toolkit.",
"license": "MIT",
"type": "library",
"keywords": [
"intermix",
"di",
"container",
- "dependency",
- "injection",
+ "dependency-injection",
+ "psr-11",
+ "invoker",
+ "serializer",
+ "attributes",
+ "scoped-services",
"macro",
"mixin",
"cache",
@@ -42,10 +46,7 @@
"autoload": {
"psr-4": {
"Infocyph\\InterMix\\": "src/"
- },
- "files": [
- "src/functions.php"
- ]
+ }
},
"autoload-dev": {
"psr-4": {
diff --git a/docs/container.rst b/docs/container.rst
index 74b0823..c9d78ec 100644
--- a/docs/container.rst
+++ b/docs/container.rst
@@ -11,9 +11,9 @@ Dependency Injection (Container)
.. code-block:: php
- use function Infocyph\InterMix\container;
+ use Infocyph\InterMix\DI\Container;
- $c = container(); // ① create
+ $c = Container::instance(); // ① create
$c->definitions()->bind('answer', 42); // ② register
echo $c->get('answer'); // ③ resolve → 42
@@ -54,7 +54,9 @@ environment-specific overrides and gives you sugar for 1-liners.
.. code-block:: php
- $c = container()
+ use Infocyph\InterMix\DI\Container;
+
+ $c = Container::instance()
->definitions()->bind(Clock::class, SystemClock::class)->end();
echo $c->get(Greeter::class)->greet('Alice');
@@ -117,7 +119,7 @@ Common quick patterns (copy-paste as you learn the rest)
$c->definitions()->bind('L1', fn()=>new ListenerA, tags:['event']);
foreach ($c->findByTag('event') as $id => $listener) {
- $listener()->handle();
+ $listener->handle();
}
*Scope isolation* ::
diff --git a/docs/di/cache.rst b/docs/di/cache.rst
index 49c8f9c..e206a5a 100644
--- a/docs/di/cache.rst
+++ b/docs/di/cache.rst
@@ -4,13 +4,14 @@
Definition-level Caching
========================
-InterMix supports caching of resolved singleton definitions through an assigned
-PHP-FIG PSR-6 cache pool.
+InterMix supports PSR-6 backed definition/cache warmup for safe cacheable definition data.
+By default, live runtime objects are not persisted to PSR-6.
+Singleton/scoped object reuse remains in the in-memory container lifetime store.
Why care?
-* **Speed** – skip repeated reflection and factory execution.
-* **Predictability** – warm and reuse resolved singleton entries.
+* **Speed** – skip repeated reflection and definition work for cache-safe values.
+* **Predictability** – warm and reuse cacheable definition data.
* **Flexibility** – use any PSR-6 implementation (for example from ``infocyph/cachelayer``).
---------------
@@ -33,12 +34,15 @@ Quick Example 🚀
// ② bind as usual
$c->definitions()->bind('answer', 42);
- // ③ first call resolves + saves
+ // ③ first call resolves + saves when the value is cache-safe
$val = $c->get('answer');
- // ④ next calls are read from cache
+ // ④ scalar/array-safe definitions may be loaded from PSR-6
echo $c->get('answer');
+Scalar/array-safe definitions may be persisted.
+Runtime service objects are kept in container memory unless ``cacheRuntimeObjects`` is explicitly enabled.
+
Eager warm-up:
.. code-block:: php
diff --git a/docs/di/cheat_sheet.rst b/docs/di/cheat_sheet.rst
index 8affe7c..798b646 100644
--- a/docs/di/cheat_sheet.rst
+++ b/docs/di/cheat_sheet.rst
@@ -138,7 +138,7 @@ Scoped resolution:
Advanced Helpers
---------------------------
-* ``$c->parseCallable($spec)``: normalize closure/function/class/method input.
+* ``$c->parseCallable($spec)``: normalize closure/function/class/method input (class-method targets must be autoloadable and exist).
* ``$c->resolveNow(...)``: resolve with explicit runtime knobs.
* ``$c->getRepository()``: inspect low-level runtime state.
* ``$c->setResolverClass(FooResolver::class)``: swap resolver implementation.
diff --git a/docs/di/definitions.rst b/docs/di/definitions.rst
index 09c92da..389c210 100644
--- a/docs/di/definitions.rst
+++ b/docs/di/definitions.rst
@@ -78,8 +78,8 @@ into internal lazy initialisers.
$def->bind('L1', fn () => new ListenerA(), tags: ['event']);
$def->bind('L2', fn () => new ListenerB(), tags: ['event']);
- foreach ($c->findByTag('event') as $id => $factory) {
- $factory()->handle();
+ foreach ($c->findByTag('event') as $id => $listener) {
+ $listener->handle();
}
Use tags for plug-in systems, domain events, command buses, etc.
diff --git a/docs/di/overview.rst b/docs/di/overview.rst
index 1ad6ae6..1793b9b 100644
--- a/docs/di/overview.rst
+++ b/docs/di/overview.rst
@@ -159,10 +159,9 @@ synchronise that cache manually.
Parameter resolution cache note
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-InterMix caches **resolution plans** (reflector metadata such as parameters and
-attribute presence) across constructor/method/property paths.
-Resolved argument **values** are only memoised for constructor paths, so
-non-constructor method calls keep per-invocation behavior.
+InterMix caches parameter resolution plans, attribute metadata and reflection-derived lookup data.
+It does not cache resolved runtime argument values inside ParameterResolver.
+Runtime object reuse is controlled by the container lifetime layer: Singleton, Scoped or Transient.
Typical lifecycle
-----------------
diff --git a/docs/di/quickstart.rst b/docs/di/quickstart.rst
index d92a8d9..97b578f 100644
--- a/docs/di/quickstart.rst
+++ b/docs/di/quickstart.rst
@@ -19,14 +19,15 @@ Each alias is an *isolated* registry of services.
use Infocyph\InterMix\DI\Container;
use function Infocyph\InterMix\container;
- $c1 = container(); // default alias is current source directory (__DIR__)
+ require_once __DIR__ . '/vendor/infocyph/intermix/src/functions.php';
+ $c1 = container(); // default alias: intermix.default
$c2 = Container::instance('cli'); // a second, independent container
// identical:
$c3 = Container::instance('cli');
Keep aliases short and memorable – tests often use a random alias to isolate state.
For production/runtime stability, prefer explicit aliases instead of relying on the
-default ``__DIR__`` alias.
+default ``intermix.default`` alias.
Configure behaviour (optional)
------------------------------
diff --git a/docs/di/tagging.rst b/docs/di/tagging.rst
index 513602d..d2537b6 100644
--- a/docs/di/tagging.rst
+++ b/docs/di/tagging.rst
@@ -32,17 +32,26 @@ Retrieve All by Tag 📬
.. code-block:: php
- foreach ($c->findByTag('event') as $id => $factory) {
- $factory()->handle($event);
+ foreach ($c->findByTag('event') as $id => $listener) {
+ $listener->handle($event);
}
* :php:meth:`Infocyph\\InterMix\\DI\\Container::findByTag` returns an **array**:
- ``[id => callable|object, …]``
-* Services are resolved **lazily** – if the definition was a class string the
- container still honours lazy loading & lifetimes.
+ ``[id => resolved service, …]``
+* ``findByTag()`` is eager and resolves the tagged services immediately.
+* For lazy factories, use :php:meth:`Infocyph\\InterMix\\DI\\Container::tagged`
+ (or ``findByTagLazy()``).
* Tag lookup is **environment-aware**. If you override tags with
``setDefinitionMetaForEnv(...)``, ``findByTag()`` uses the active environment.
+Lazy variant:
+
+.. code-block:: php
+
+ foreach ($c->tagged('event') as $id => $factory) {
+ $factory()->handle($event);
+ }
+
-----------------------
Multiple Tags per ID
-----------------------
diff --git a/docs/fence.rst b/docs/fence.rst
index b7df132..cf47a31 100644
--- a/docs/fence.rst
+++ b/docs/fence.rst
@@ -21,23 +21,24 @@ Key Concepts
Implements all logic for requirement checks, keyed vs. singleton behavior,
and instance‐count limits. You do **not** use ``Fence`` directly; one of the three
-wrapper traits sets two class‐constants and the core logic runs on every
-``::instance()`` call.
+wrapper traits sets two class‐constants and the core logic resolves/retrieves
+instances through ``::instance()``.
**Singleton (Single)**
-Classes define ``FENCE_KEYED = true``. Only one instance can exist.
-Key is optional; defaults to ``'__single'``.
+Classes define ``FENCE_KEYED = false``. Only one instance can exist.
+Key is ignored and stored in the internal ``'__single'`` slot.
**Multiton (Multi)**
-Classes define ``FENCE_KEYED = false``. Multiple instances allowed, keyed by first argument.
-Key is required; defaults to ``'default'``.
+Classes define ``FENCE_KEYED = true``. Multiple instances allowed, keyed by first argument.
+If omitted, key defaults to ``'default'``.
**Limited Multiton (Limit)**
Extends Multi with configurable instance limits.
-Classes define ``FENCE_LIMIT = ``.
+Classes define ``FENCE_KEYED = true`` and ``FENCE_LIMIT = ``.
+Caller-provided keys are used until the configured limit is reached.
``setLimit(int)`` changes the limit at runtime.
**Constants Available**
@@ -49,7 +50,7 @@ Classes define ``FENCE_LIMIT = ``.
``::instance()`` accepts optional constraints array with ``extensions`` and/or
``classes``. If any extension or class is missing, a ``RequirementException`` is thrown
-before any instance is created.
+before a **new** instance is created.
**New Features Added**
@@ -125,7 +126,7 @@ before any instance is created.
``::instance()`` accepts an optional constraints array with ``extensions`` and/or
``classes``. If any extension or class is missing, a ``RequirementException`` is thrown
-before any instance is created.
+before any new instance is created.
**Limit Enforcement**
@@ -234,3 +235,4 @@ Best Practices
* If you need per‐key instances, use ``Multi``.
* If you want to cap how many objects can coexist, use ``Limit``.
* To enforce startup requirements, pass a constraints array to ``::instance()`` and catch ``RequirementException``.
+* Prefer ``countInstances()``, ``hasInstance()`` and ``getKeys()`` for runtime inspection; use ``getInstances()`` mainly for debug/tests.
diff --git a/docs/functions.rst b/docs/functions.rst
index 19fe405..19795f3 100644
--- a/docs/functions.rst
+++ b/docs/functions.rst
@@ -4,8 +4,15 @@
Global Functions Reference
==========================
-InterMix autoloads helper functions from ``src/functions.php``.
-This page documents each public helper and its runtime behavior.
+Global helpers are optional.
+InterMix does **not** autoload ``src/functions.php`` by default.
+Load helpers manually when you want them:
+
+.. code-block:: php
+
+ require_once __DIR__ . '/vendor/infocyph/intermix/src/functions.php';
+
+This page documents each helper and its runtime behavior.
DI Helpers
----------
@@ -13,11 +20,11 @@ DI Helpers
container()
===========
-.. php:function:: container(string|Closure|callable|array|null $closureOrClass = null, string $alias = __DIR__): mixed
+.. php:function:: container(string|Closure|callable|array|null $closureOrClass = null, string $alias = Container::DEFAULT_ALIAS): mixed
-Get the helper container (default alias ``__DIR__``), or resolve/call immediately.
+Get the helper container (default alias ``intermix.default``), or resolve/call immediately.
-- ``container()`` returns ``Container::instance(__DIR__)``.
+- ``container()`` returns ``Container::instance('intermix.default')``.
- ``container('my-alias')`` resolves ``'my-alias'`` as a service/callable.
- For explicit named containers, use ``Container::instance('my-alias')``.
- For stable runtime behavior, prefer explicit aliases across bootstrap and app code.
@@ -25,7 +32,7 @@ Get the helper container (default alias ``__DIR__``), or resolve/call immediatel
resolve()
=========
-.. php:function:: resolve(string|Closure|callable|array|null $spec = null, array $parameters = [], string $alias = __DIR__ . 'DI'): mixed
+.. php:function:: resolve(string|Closure|callable|array|null $spec = null, array $parameters = [], string $alias = Container::DI_ALIAS): mixed
Resolve immediately with DI/autowiring path enabled.
@@ -35,7 +42,7 @@ Resolve immediately with DI/autowiring path enabled.
direct()
========
-.. php:function:: direct(string|Closure|callable|array|null $spec = null, array $parameters = [], string $alias = __DIR__ . 'DR'): mixed
+.. php:function:: direct(string|Closure|callable|array|null $spec = null, array $parameters = [], string $alias = Container::DIRECT_ALIAS): mixed
Resolve immediately with injection disabled (generic invocation path).
diff --git a/docs/remix.rst b/docs/remix.rst
index 6174f52..e9a4453 100644
--- a/docs/remix.rst
+++ b/docs/remix.rst
@@ -4,8 +4,8 @@
Remix – Fluent PHP Helpers
===========================
-``Infocyph\InterMix\Remix`` is a small collection of *opt-in* traits, proxies and
-global helper functions that add fluency to any PHP codebase. Each component is purely “opt-in.”
+``Infocyph\InterMix\Remix`` is a small collection of *opt-in* optional helper functions
+and fluent traits/proxies. Each component is purely “opt-in.”
You pick and choose only what you need.
.. toctree::
diff --git a/docs/remix/helpers.rst b/docs/remix/helpers.rst
index 07e3a11..63c2e0d 100644
--- a/docs/remix/helpers.rst
+++ b/docs/remix/helpers.rst
@@ -5,8 +5,14 @@ Global Helper Functions
=========================
In addition to :php:func:`tap`, Remix provides several *global functions* to make
-common tasks more fluent. They are declared in ``src/functions.php`` and are
-available as global helpers.
+common tasks more fluent. They are declared in ``src/functions.php``.
+
+Global helper loading is optional: the package does not autoload ``src/functions.php``.
+Load it manually when needed:
+
+.. code-block:: php
+
+ require_once __DIR__ . '/vendor/infocyph/intermix/src/functions.php';
- **tap()** – covered in :ref:`remix.tap-proxy`.
- **when()** – conditionally apply callbacks to values.
diff --git a/src/DI/Container.php b/src/DI/Container.php
index bfae8c7..647be47 100644
--- a/src/DI/Container.php
+++ b/src/DI/Container.php
@@ -15,8 +15,13 @@
use Infocyph\InterMix\DI\Managers\OptionsManager;
use Infocyph\InterMix\DI\Managers\RegistrationManager;
use Infocyph\InterMix\DI\Resolver\Repository;
+use Infocyph\InterMix\DI\Support\CompiledResolverGenerator;
use Infocyph\InterMix\DI\Support\ContainerProxy;
+use Infocyph\InterMix\DI\Support\ContextualBindingBuilder;
use Infocyph\InterMix\DI\Support\DebugTracer;
+use Infocyph\InterMix\DI\Support\LifetimeEnum;
+use Infocyph\InterMix\DI\Support\PendingFactoryBinding;
+use Infocyph\InterMix\DI\Support\TaggedPipeline;
use Infocyph\InterMix\DI\Support\TraceLevelEnum;
use Infocyph\InterMix\Exceptions\ContainerException;
use Infocyph\InterMix\Exceptions\NotFoundException;
@@ -32,9 +37,17 @@ final class Container implements ContainerInterface, ArrayAccess
{
use ContainerProxy;
+ public const string DEFAULT_ALIAS = 'intermix.default';
+
+ public const string DI_ALIAS = 'intermix.di';
+
+ public const string DIRECT_ALIAS = 'intermix.direct';
+
/** @var array */
protected static array $instances = [];
+ private static int $generatedClosureSequence = 0;
+
protected DefinitionManager $definitionManager;
protected InvocationManager $invocationManager;
@@ -54,7 +67,7 @@ final class Container implements ContainerInterface, ArrayAccess
* Defaults to 'default'.
* @throws ContainerException
*/
- public function __construct(private readonly string $instanceAlias = __DIR__)
+ public function __construct(private readonly string $instanceAlias = self::DEFAULT_ALIAS)
{
self::$instances[$this->instanceAlias] ??= $this;
$this->repository = new Repository($this);
@@ -82,11 +95,21 @@ public function __construct(private readonly string $instanceAlias = __DIR__)
* @return static The container instance.
* @throws ContainerException
*/
- public static function instance(string $instanceAlias = __DIR__): self
+ public static function instance(string $instanceAlias = self::DEFAULT_ALIAS): self
{
return self::$instances[$instanceAlias] ??= new self($instanceAlias);
}
+ /**
+ * @throws ContainerException
+ */
+ public function alias(string $id, string $target, LifetimeEnum $lifetime = LifetimeEnum::Singleton): self
+ {
+ $this->definitions()->bind($id, $target, $lifetime);
+
+ return $this;
+ }
+
/**
* Retrieve the attribute registry.
*
@@ -101,6 +124,21 @@ public function attributeRegistry(): AttributeRegistry
return $this->repository->attributeRegistry();
}
+ /**
+ * @param array $tags
+ * @throws ContainerException
+ */
+ public function bind(
+ string $id,
+ mixed $definition,
+ LifetimeEnum $lifetime = LifetimeEnum::Singleton,
+ array $tags = [],
+ ): self {
+ $this->definitions()->bind($id, $definition, $lifetime, $tags);
+
+ return $this;
+ }
+
/**
* Resolves a class with method name (if provided) and executes the method with optional parameters.
*
@@ -117,6 +155,19 @@ public function call(string|Closure|callable $classOrClosure, string|bool|null $
return $this->invocationManager->call($classOrClosure, $method);
}
+ /**
+ * @throws ReflectionException
+ */
+ public function compileTo(string $path, bool $load = false): self
+ {
+ $compiled = (new CompiledResolverGenerator())->generate($this, $path);
+ if ($load) {
+ $this->repository->setCompiledResolvers($compiled);
+ }
+
+ return $this;
+ }
+
/**
* Debugs the service resolution process for a given ID.
*
@@ -130,16 +181,22 @@ public function call(string|Closure|callable $classOrClosure, string|bool|null $
*/
public function debug(string $id): array
{
+ $tracer = $this->repository->tracer();
+ $previousLevel = $tracer->level();
+ $previousCaptureLocation = $tracer->isCaptureLocationEnabled();
+
try {
- $tracer = $this->repository->tracer();
$tracer->setCaptureLocation(true);
$tracer->setLevel(TraceLevelEnum::Verbose);
$this->get($id);
} catch (Throwable) {
// swallow; we still want trace
+ } finally {
+ $tracer->setCaptureLocation($previousCaptureLocation);
+ $tracer->setLevel($previousLevel);
}
- return $this->repository->tracer()->toArray();
+ return $tracer->toArray();
}
/**
@@ -216,6 +273,14 @@ public function exportGraph(?string $warmFromId = null, bool $clear = false): ar
return $this->repository->tracer()->dependencyGraph($clear);
}
+ /**
+ * @throws ContainerException
+ */
+ public function factory(string $id, Closure $factory): PendingFactoryBinding
+ {
+ return new PendingFactoryBinding($this, $id, $factory);
+ }
+
/**
* Finds and retrieves all service definitions tagged with a specified tag.
*
@@ -238,6 +303,16 @@ public function findByTag(string $tag): array
return $matches;
}
+ /**
+ * @return iterable
+ */
+ public function findByTagLazy(string $tag): iterable
+ {
+ foreach ($this->repository->getIdsByTag($tag) as $id) {
+ yield $id => fn() => $this->get($id);
+ }
+ }
+
/**
* Retrieves a value from the container.
*
@@ -399,6 +474,36 @@ public function make(string $class, string|bool $method = false): mixed
return $this->invocationManager->make($class, $method);
}
+ /**
+ * @throws ContainerException
+ */
+ public function onResolved(string $id, callable $callback): self
+ {
+ $this->repository->onResolved($id, $callback);
+
+ return $this;
+ }
+
+ /**
+ * @throws ContainerException
+ */
+ public function onResolving(string $id, callable $callback): self
+ {
+ $this->repository->onResolving($id, $callback);
+
+ return $this;
+ }
+
+ /**
+ * @throws ContainerException
+ */
+ public function onScopeLeave(string $scope, callable $callback): self
+ {
+ $this->repository->onScopeLeave($scope, $callback);
+
+ return $this;
+ }
+
/**
* Retrieve the options manager.
*
@@ -425,7 +530,10 @@ public function options(): OptionsManager
* - callable object (e.g. closure, invokable class)
* - array of class and method name
*
- * @param string|array{0:string,1:string}|Closure|callable $spec The callable string/array to parse.
+ * Class-method targets are validated eagerly, so referenced classes must be
+ * autoloadable and methods must exist at parse time.
+ *
+ * @param string|array|Closure|callable $spec The callable string/array to parse.
* @return array{kind:'closure',closure:callable}
* |array{kind:'class',class:string}
* |array{kind:'method',class:string,method:string}
@@ -444,22 +552,14 @@ public function parseCallable(string|array|Closure|callable $spec): array
=> ['kind' => 'closure', 'closure' => $spec],
is_array($spec) && count($spec) === 2 && is_string($spec[0]) && is_string($spec[1])
- => ['kind' => 'method', 'class' => $spec[0], 'method' => $spec[1]],
+ => $this->parseArrayMethodCallable($spec),
is_string($spec) => match (true) {
str_contains($spec, '@')
- => (static function () use ($spec) {
- [$cls, $m] = explode('@', $spec, 2);
-
- return ['kind' => 'method', 'class' => $cls, 'method' => $m];
- })(),
+ => $this->parseClassMethodString($spec, '@'),
str_contains($spec, '::')
- => (static function () use ($spec) {
- [$cls, $m] = explode('::', $spec, 2);
-
- return ['kind' => 'method', 'class' => $cls, 'method' => $m];
- })(),
+ => $this->parseClassMethodString($spec, '::'),
class_exists($spec)
=> ['kind' => 'class', 'class' => $spec],
@@ -490,6 +590,11 @@ function_exists($spec)
};
}
+ public function pipeline(string $tag): TaggedPipeline
+ {
+ return new TaggedPipeline($this, $tag);
+ }
+
/**
* Retrieve the registration manager.
*
@@ -532,6 +637,22 @@ public function resolveNow(
};
}
+ /**
+ * @param array $tags
+ * @throws ContainerException
+ */
+ public function scoped(string $id, mixed $definition = null, array $tags = []): self
+ {
+ $this->definitions()->bind(
+ $id,
+ $definition ?? $id,
+ LifetimeEnum::Scoped,
+ $tags,
+ );
+
+ return $this;
+ }
+
/**
* Sets the environment for the container.
*
@@ -565,6 +686,31 @@ public function setResolverClass(string $resolverClass): void
$this->resolver = fn() => new $resolverClass($this->repository);
}
+ /**
+ * @param array $tags
+ * @throws ContainerException
+ */
+ public function singleton(string $id, mixed $definition = null, array $tags = []): self
+ {
+ $this->definitions()->bind(
+ $id,
+ $definition ?? $id,
+ LifetimeEnum::Singleton,
+ $tags,
+ );
+
+ return $this;
+ }
+
+ /**
+ * @return iterable
+ * @throws ContainerException
+ */
+ public function tagged(string $tag): iterable
+ {
+ return $this->findByTagLazy($tag);
+ }
+
/**
* Retrieves the debug tracer instance.
*
@@ -580,6 +726,22 @@ public function tracer(): DebugTracer
return $this->repository->tracer();
}
+ /**
+ * @param array $tags
+ * @throws ContainerException
+ */
+ public function transient(string $id, mixed $definition = null, array $tags = []): self
+ {
+ $this->definitions()->bind(
+ $id,
+ $definition ?? $id,
+ LifetimeEnum::Transient,
+ $tags,
+ );
+
+ return $this;
+ }
+
/**
* Remove the container instance from the registry.
*
@@ -593,6 +755,54 @@ public function unset(): void
unset(self::$instances[$this->instanceAlias]);
}
+ /**
+ * @throws ContainerException
+ */
+ public function useCompiled(string $path): self
+ {
+ $compiled = require $path;
+ if (!is_array($compiled)) {
+ throw new ContainerException('Compiled resolver file must return an array.');
+ }
+ /** @var array $compiled */
+ $this->repository->setCompiledResolvers($compiled);
+
+ return $this;
+ }
+
+ /**
+ * @return array
+ * @throws ContainerException
+ */
+ public function validate(bool $strict = false, bool $resolveFactories = false): array
+ {
+ $issues = $this->validateDefinitionTargets();
+ if ($resolveFactories) {
+ $issues = array_merge($issues, $this->validateResolvableDefinitions());
+ }
+
+ if ($strict && $issues !== []) {
+ throw new ContainerException("Container validation failed:\n- " . implode("\n- ", $issues));
+ }
+
+ return $issues;
+ }
+
+ /**
+ * @throws ContainerException
+ */
+ public function value(string $id, mixed $value): self
+ {
+ $this->definitions()->bind($id, $value, LifetimeEnum::Singleton);
+
+ return $this;
+ }
+
+ public function when(string $consumer): ContextualBindingBuilder
+ {
+ return new ContextualBindingBuilder($this, $consumer);
+ }
+
/**
* Execute a callback inside a named scope and always leave it afterward.
*
@@ -620,6 +830,98 @@ public function withinScope(string $scope, callable $callback): mixed
}
}
+ /**
+ * @param array $spec
+ * @return array{kind:'method',class:string,method:string}
+ */
+ private function parseArrayMethodCallable(array $spec): array
+ {
+ [$class, $method] = array_values($spec);
+
+ if (!is_string($class) || !is_string($method)) {
+ throw new ContainerException(
+ 'Invalid callable array. Expected [class, method] with non-empty strings.',
+ );
+ }
+
+ return $this->parseClassMethodParts($class, $method, '[class, method]');
+ }
+
+ /**
+ * @return array{kind:'method',class:string,method:string}
+ */
+ private function parseClassMethodParts(
+ string $class,
+ string $method,
+ string $spec,
+ ?string $separator = null,
+ ): array {
+ $class = trim($class);
+ $method = trim($method);
+
+ if ($class === '' || $method === '') {
+ if ($separator === null) {
+ throw new ContainerException(
+ 'Invalid callable array. Expected [class, method] with non-empty strings.',
+ );
+ }
+
+ throw new ContainerException(
+ sprintf(
+ 'Invalid callable string "%s". Expected non-empty class and method around "%s".',
+ $spec,
+ $separator,
+ ),
+ );
+ }
+
+ if (!class_exists($class)) {
+ if ($separator === null) {
+ throw new ContainerException(
+ sprintf('Invalid callable array. Class "%s" does not exist.', $class),
+ );
+ }
+
+ throw new ContainerException(
+ sprintf('Invalid callable string "%s". Class "%s" does not exist.', $spec, $class),
+ );
+ }
+
+ if (!method_exists($class, $method)) {
+ if ($separator === null) {
+ throw new ContainerException(
+ sprintf('Invalid callable array. Method "%s::%s" does not exist.', $class, $method),
+ );
+ }
+
+ throw new ContainerException(
+ sprintf(
+ 'Invalid callable string "%s". Method "%s::%s" does not exist.',
+ $spec,
+ $class,
+ $method,
+ ),
+ );
+ }
+
+ return [
+ 'kind' => 'method',
+ 'class' => $class,
+ 'method' => $method,
+ ];
+ }
+
+ /**
+ * @param non-empty-string $separator
+ * @return array{kind:'method',class:string,method:string}
+ */
+ private function parseClassMethodString(string $spec, string $separator): array
+ {
+ [$class, $method] = explode($separator, $spec, 2);
+
+ return $this->parseClassMethodParts($class, $method, $spec, $separator);
+ }
+
/**
* @param array{kind:'class',class:string}|array{kind:'method',class:string,method:string} $desc
* @param array $parameters
@@ -648,7 +950,8 @@ private function resolveRegisteredClosureCallable(array $desc, array $parameters
}
$callback = $id(...);
} else {
- $id = random_bytes(5);
+ self::$generatedClosureSequence++;
+ $id = '__imx_closure_' . self::$generatedClosureSequence;
$callback = $desc['closure'];
}
@@ -657,6 +960,52 @@ private function resolveRegisteredClosureCallable(array $desc, array $parameters
return $this->getReturn($id);
}
+ /**
+ * @return array
+ */
+ private function validateDefinitionTargets(): array
+ {
+ $issues = [];
+ foreach ($this->repository->getFunctionReference() as $id => $definition) {
+ if ($id === '') {
+ $issues[] = 'Invalid definition id detected.';
+
+ continue;
+ }
+
+ if (is_string($definition)
+ && !class_exists($definition)
+ && !function_exists($definition)
+ && !$this->repository->hasFunctionReference($definition)
+ ) {
+ $issues[] = "Definition '{$id}' points to unknown string target '{$definition}'.";
+ }
+
+ if (is_array($definition) && isset($definition[0]) && is_string($definition[0]) && !class_exists($definition[0])) {
+ $issues[] = "Definition '{$id}' references missing class '{$definition[0]}'.";
+ }
+ }
+
+ return $issues;
+ }
+
+ /**
+ * @return array
+ */
+ private function validateResolvableDefinitions(): array
+ {
+ $issues = [];
+ foreach (array_keys($this->repository->getFunctionReference()) as $id) {
+ try {
+ $this->get($id);
+ } catch (Throwable $e) {
+ $issues[] = "Resolution failure for '{$id}': {$e->getMessage()}";
+ }
+ }
+
+ return $issues;
+ }
+
/**
* Wraps an exception into a NotFoundException if it's a NotFoundException,
* or a ContainerException with the given ID otherwise.
diff --git a/src/DI/Invoker.php b/src/DI/Invoker.php
index 6420f0e..b7fa7dd 100644
--- a/src/DI/Invoker.php
+++ b/src/DI/Invoker.php
@@ -8,7 +8,6 @@
use Infocyph\InterMix\Exceptions\ContainerException;
use Infocyph\InterMix\Serializer\ValueSerializer;
use InvalidArgumentException;
-use Random\RandomException;
use ReflectionException;
final class Invoker
@@ -37,7 +36,7 @@ private function __construct(private readonly Container $container) {}
public static function shared(): self
{
if (!self::$sharedInstance instanceof self) {
- self::$sharedInstance = new self(Container::instance(__DIR__));
+ self::$sharedInstance = new self(Container::instance(Container::DI_ALIAS));
}
return self::$sharedInstance;
@@ -72,25 +71,32 @@ public static function with(Container $container): self
*/
public function callableFor(string|object $target): callable
{
- $key = \is_string($target) ? $target : $target::class;
+ if (\is_object($target)) {
+ if (!\is_callable($target)) {
+ throw new InvalidArgumentException(
+ sprintf('%s is not invokable.', $target::class),
+ );
+ }
+
+ return $target(...);
+ }
+ $key = 'container:' . spl_object_id($this->container) . ':class:' . $target;
if (isset(self::$callableCache[$key])) {
return self::$callableCache[$key];
}
- $instance = \is_string($target) ? $this->make($target) : $target;
+ $instance = $this->make($target);
if (!\is_object($instance) || !\is_callable($instance)) {
throw new InvalidArgumentException(
sprintf(
'%s is not invokable.',
- \is_object($instance) ? $instance::class : $key,
+ $target,
),
);
}
- self::$callableCache[$key] = $instance(...);
-
- return self::$callableCache[$key];
+ return self::$callableCache[$key] = $instance(...);
}
/**
@@ -118,7 +124,7 @@ public function getContainer(): Container
*
* @throws ContainerException If the callable cannot be resolved.
* @throws ReflectionException If the callable is a class without a default constructor.
- * @throws InvalidArgumentException|RandomException If the callable is not a valid callable.
+ * @throws InvalidArgumentException If the callable is not a valid callable.
*/
public function invoke(string|array|callable $target, array $args = []): mixed
{
@@ -148,6 +154,19 @@ public function invoke(string|array|callable $target, array $args = []): mixed
};
}
+ /**
+ * Invoke a closure directly through the active resolver without registering aliases.
+ *
+ * @param array $args
+ * @throws ContainerException|ReflectionException|\Psr\Cache\InvalidArgumentException
+ */
+ public function invokeClosure(Closure $closure, array $args = []): mixed
+ {
+ return $this->container
+ ->getCurrentResolver()
+ ->closureSettler($closure, $args);
+ }
+
/**
* Create a new instance of a class, optionally invoking a method.
*
@@ -245,7 +264,7 @@ public function unserialize(string $b): mixed
* @param mixed $callable The callable to be routed and executed.
* @param array $args The arguments to pass to the callable.
* @return mixed The result of executing the callable.
- * @throws ContainerException|RandomException|ReflectionException|\Psr\Cache\InvalidArgumentException
+ * @throws ContainerException|ReflectionException|\Psr\Cache\InvalidArgumentException
*/
private function routeCallable(mixed $callable, array $args): mixed
{
@@ -278,19 +297,10 @@ private function routeCallable(mixed $callable, array $args): mixed
* @param Closure $fn The closure to invoke.
* @param array $args The arguments to pass to the closure.
* @return mixed The result of the closure invocation.
- * @throws ContainerException|ReflectionException|\Psr\Cache\InvalidArgumentException|RandomException
+ * @throws ContainerException|ReflectionException|\Psr\Cache\InvalidArgumentException
*/
private function viaClosure(Closure $fn, array $args): mixed
{
- static $i = 0;
- if (!is_int($i)) {
- $i = 0;
- }
- $alias = 'λ' . $i;
- $i++;
-
- return $this->container
- ->registration()->registerClosure($alias, $fn, $args)
- ->invocation()->getReturn($alias);
+ return $this->invokeClosure($fn, $args);
}
}
diff --git a/src/DI/Managers/DefinitionManager.php b/src/DI/Managers/DefinitionManager.php
index a55bc06..17f3553 100644
--- a/src/DI/Managers/DefinitionManager.php
+++ b/src/DI/Managers/DefinitionManager.php
@@ -75,8 +75,8 @@ public function bind(
LifetimeEnum $lifetime = LifetimeEnum::Singleton,
array $tags = [],
): self {
- if ($id === $definition) {
- throw new ContainerException("Id and definition cannot be the same ($id)");
+ if (is_string($definition) && $id === $definition && !class_exists($definition)) {
+ throw new ContainerException("Scalar/string alias cannot point to itself ($id).");
}
$this->repository->setFunctionReference($id, $definition);
$this->repository->setDefinitionMeta($id, [
@@ -135,9 +135,12 @@ public function cacheAllDefinitions(bool $forceClearFirst = false): self
* @return $this
* @throws ContainerException
*/
- public function enableDefinitionCache(CacheItemPoolInterface $adapter): self
- {
+ public function enableDefinitionCache(
+ CacheItemPoolInterface $adapter,
+ bool $cacheRuntimeObjects = false,
+ ): self {
$this->repository->setCacheAdapter($adapter);
+ $this->repository->setCacheRuntimeObjects($cacheRuntimeObjects);
return $this;
}
diff --git a/src/DI/Managers/InvocationManager.php b/src/DI/Managers/InvocationManager.php
index da6b90a..e7e4456 100644
--- a/src/DI/Managers/InvocationManager.php
+++ b/src/DI/Managers/InvocationManager.php
@@ -42,7 +42,8 @@ public function __construct(
* does the following:
*
* 1. If $classOrClosure is a string and exists in the function references,
- * the definition is resolved using the RepositoryResolver.
+ * the definition is resolved through get() so lifetime/scope caches and
+ * lifecycle hooks are preserved.
*
* 2. If $classOrClosure is a closure or callable, the closure is invoked
* with resolved parameters using the RepositoryResolver.
@@ -67,7 +68,7 @@ public function call(string|Closure|callable $classOrClosure, string|bool|null $
// 1) If string & in functionReference
if (is_string($classOrClosure) && $this->repository->hasFunctionReference($classOrClosure)) {
- return $resolver->resolveByDefinition($classOrClosure);
+ return $this->get($classOrClosure);
}
// 2) If a closure/callable
@@ -119,20 +120,27 @@ public function definitions(): DefinitionManager
*/
public function get(string $id): mixed
{
- $this->repository->tracer()->push("return:$id", TraceLevelEnum::Verbose);
+ $lifetime = null;
+ $resolved = $this->repository->getResolvedEntry($id);
+ if ($resolved !== null || $this->repository->hasResolved($id)) {
+ $lifetime = $this->repository->getDefinitionLifetime($id);
+ }
- $lifetime = $this->repository->getDefinitionLifetime($id);
if ($lifetime === LifetimeEnum::Singleton) {
- $resolved = $this->repository->getResolvedEntry($id);
- if ($resolved !== null || $this->repository->hasResolved($id)) {
- if ($resolved instanceof DeferredInitializer) {
- $resolved = $resolved();
- $this->repository->setResolved($id, $resolved);
- }
-
- return $this->repository->fetchInstanceOrValue($resolved);
+ if ($resolved instanceof DeferredInitializer) {
+ $resolved = $resolved();
+ $this->repository->setResolved($id, $resolved);
}
+ return $this->repository->fetchInstanceOrValue($resolved);
+ }
+
+ if ($this->repository->isTracingEnabled()) {
+ $this->repository->tracer()->push("return:$id", TraceLevelEnum::Verbose);
+ }
+
+ $lifetime ??= $this->repository->getDefinitionLifetime($id);
+ if ($lifetime === LifetimeEnum::Singleton) {
return $this->resolveAndCache($id, $id, true, false);
}
@@ -164,7 +172,9 @@ public function get(string $id): mixed
public function getReturn(string $id): mixed
{
$resolved = $this->get($id);
- $resource = $this->repository->getResolvedEntry($id) ?? [];
+ $lifetime = $this->repository->getDefinitionLifetime($id);
+ $key = $this->scopeKeyFor($id, $lifetime);
+ $resource = $this->repository->getResolvedEntry($key) ?? [];
return is_array($resource) && array_key_exists('returned', $resource) ? $resource['returned'] : $resolved;
}
@@ -251,20 +261,18 @@ protected function resolveDefinition(string $id): mixed
$definition = $this->repository->getFunctionDefinition($id);
if ($this->repository->isLazyLoading() && !($definition instanceof Closure)) {
- $lazy = new DeferredInitializer(fn() => $resolver->resolveByDefinition($id), $this->container);
- $this->repository->setResolved($id, $lazy);
-
- return $lazy;
+ return new DeferredInitializer(fn() => $resolver->resolveByDefinition($id), $this->container);
}
$value = $resolver->resolveByDefinition($id);
- $this->repository->setResolved($id, $value);
return $this->repository->fetchInstanceOrValue($value);
}
private function resolveAndCache(string $id, string $scopeKey, bool $cacheable, bool $isScoped): mixed
{
+ $this->repository->dispatchResolvingHooks($id);
+
if ($this->repository->hasFunctionReference($id)) {
$resolved = $this->resolveDefinition($id);
$resolved = $resolved instanceof DeferredInitializer ? $resolved() : $resolved;
@@ -273,6 +281,8 @@ private function resolveAndCache(string $id, string $scopeKey, bool $cacheable,
$this->storeResolvedByLifetime($scopeKey, $resolved, $isScoped);
}
+ $this->repository->dispatchResolvedHooks($id, $resolved);
+
return $resolved;
}
@@ -281,6 +291,8 @@ private function resolveAndCache(string $id, string $scopeKey, bool $cacheable,
$this->storeResolvedByLifetime($scopeKey, $resolved, $isScoped);
}
+ $this->repository->dispatchResolvedHooks($id, $resolved);
+
return $this->repository->fetchInstanceOrValue($resolved);
}
diff --git a/src/DI/Resolver/ClassResolver.php b/src/DI/Resolver/ClassResolver.php
index 0d19fb8..175dbe3 100644
--- a/src/DI/Resolver/ClassResolver.php
+++ b/src/DI/Resolver/ClassResolver.php
@@ -420,20 +420,26 @@ private function resolveMake(
string $className,
string|bool|null $callMethod,
): array {
- $existing = $this->repository->getResolvedResourceFor($className);
+ $allExisting = $this->repository->getResolvedResource();
+ $hadExisting = array_key_exists($className, $allExisting);
+ $existing = $allExisting[$className] ?? [];
- // build fresh
- $this->resolveConstructor($class);
- $this->propertyResolver->resolve($class);
- $this->resolveMethod($class, $callMethod);
-
- $newlyBuilt = $this->normalizeResolvedResource(
- $this->repository->getResolvedResourceFor($className),
- );
- // revert the old
- $this->repository->setResolvedResource($className, $existing);
+ try {
+ // build fresh
+ $this->resolveConstructor($class);
+ $this->propertyResolver->resolve($class);
+ $this->resolveMethod($class, $callMethod);
- return $newlyBuilt;
+ return $this->normalizeResolvedResource(
+ $this->repository->getResolvedResourceFor($className),
+ );
+ } finally {
+ if ($hadExisting) {
+ $this->repository->setResolvedResource($className, $existing);
+ } else {
+ $this->repository->unsetResolvedResource($className);
+ }
+ }
}
/**
diff --git a/src/DI/Resolver/Concerns/ResolvesAssociativeParameters.php b/src/DI/Resolver/Concerns/ResolvesAssociativeParameters.php
index b112190..022cb64 100644
--- a/src/DI/Resolver/Concerns/ResolvesAssociativeParameters.php
+++ b/src/DI/Resolver/Concerns/ResolvesAssociativeParameters.php
@@ -197,6 +197,14 @@ private function tryResolveAssociative(
$classReflection = $this->getResolvableReflection($reflector, $param, $type, $processed);
if ($classReflection) {
+ $contextual = $this->resolveContextualDependency(
+ $this->ownerFor($reflector),
+ $classReflection,
+ );
+ if ($contextual !== $this->stdClass) {
+ return $contextual;
+ }
+
$nameHint = $classReflection->isInterface()
? $classReflection->getName()
: $paramName;
diff --git a/src/DI/Resolver/DefinitionResolver.php b/src/DI/Resolver/DefinitionResolver.php
index 67f1e73..1355c03 100644
--- a/src/DI/Resolver/DefinitionResolver.php
+++ b/src/DI/Resolver/DefinitionResolver.php
@@ -114,26 +114,14 @@ private function getFromCacheOrResolve(string $name): mixed
return $this->resolveDefinition($name);
}
- if (!$this->repository->hasResolvedDefinition($resolvedKey)) {
- $resolverCallback = fn() => $this->resolveDefinition($name);
- $cacheAdapter = $this->repository->getCacheAdapter();
- if ($cacheAdapter) {
- $cacheKey = $this->repository->makeCacheKey('def:' . self::stableHash($resolvedKey));
- $item = $cacheAdapter->getItem($cacheKey);
- if ($item->isHit()) {
- $value = $item->get();
- } else {
- $value = $resolverCallback();
- $item->set($value);
- $cacheAdapter->save($item);
- }
- } else {
- $value = $resolverCallback();
- }
- $this->repository->setResolvedDefinition($resolvedKey, $value);
+ if ($this->repository->hasResolvedDefinition($resolvedKey)) {
+ return $this->repository->getResolvedDefinitionEntry($resolvedKey);
}
- return $this->repository->getResolvedDefinitionEntry($resolvedKey);
+ $value = $this->resolveSingletonDefinition($name, $resolvedKey);
+ $this->repository->setResolvedDefinition($resolvedKey, $value);
+
+ return $value;
}
/**
@@ -181,6 +169,11 @@ private function resolveArrayDefinition(array $definition): mixed
*/
private function resolveDefinition(string $name): mixed
{
+ $compiled = $this->repository->getCompiledResolver($name);
+ if (is_callable($compiled)) {
+ return $compiled($this->repository->container());
+ }
+
$definition = $this->repository->getFunctionDefinition($name);
switch (true) {
case $definition instanceof Closure:
@@ -206,4 +199,29 @@ private function resolveDefinition(string $name): mixed
return $definition;
}
}
+
+ private function resolveSingletonDefinition(string $name, string $resolvedKey): mixed
+ {
+ $cacheAdapter = $this->repository->getCacheAdapter();
+ if ($cacheAdapter === null) {
+ return $this->resolveDefinition($name);
+ }
+
+ $cacheKey = $this->repository->makeCacheKey('def:' . self::stableHash($resolvedKey));
+ $item = $cacheAdapter->getItem($cacheKey);
+ if ($item->isHit()) {
+ $cachedValue = $item->get();
+ if ($this->repository->shouldPersistDefinitionValue($cachedValue)) {
+ return $cachedValue;
+ }
+ }
+
+ $value = $this->resolveDefinition($name);
+ if ($this->repository->shouldPersistDefinitionValue($value)) {
+ $item->set($value);
+ $cacheAdapter->save($item);
+ }
+
+ return $value;
+ }
}
diff --git a/src/DI/Resolver/ParameterResolver.php b/src/DI/Resolver/ParameterResolver.php
index 8041447..0c93590 100644
--- a/src/DI/Resolver/ParameterResolver.php
+++ b/src/DI/Resolver/ParameterResolver.php
@@ -47,8 +47,6 @@ class ParameterResolver
private const int PARAM_ATTRIBUTE_PLAN_CACHE_LIMIT = 4096;
- private const int RESOLUTION_CACHE_LIMIT = 4096;
-
private const int RESOLUTION_PLAN_CACHE_LIMIT = 2048;
private readonly IMStdClass $stdClass;
@@ -73,9 +71,6 @@ class ParameterResolver
*/
private array $resolutionPlanCache = [];
- /** @var array> */
- private array $resolvedCache = [];
-
public function __construct(
private readonly Repository $repository,
private readonly DefinitionResolver $definitionResolver,
@@ -96,16 +91,6 @@ public function resolve(
array $suppliedParameters,
string $type,
): array {
- $useValueCache = $this->shouldUseValueCache($reflector, $type);
- $cacheKey = null;
-
- if ($useValueCache) {
- $cacheKey = $this->makeResolutionCacheKey($reflector, $suppliedParameters, $type);
- if (array_key_exists($cacheKey, $this->resolvedCache)) {
- return $this->resolvedCache[$cacheKey];
- }
- }
-
$this->repository->tracer()->push(
"{$reflector->getShortName()}() params",
TraceLevelEnum::Verbose,
@@ -114,7 +99,7 @@ public function resolve(
$plan = $this->getResolutionPlan($reflector, $type);
$availableParams = $plan['availableParams'];
if (!$availableParams) {
- return $useValueCache ? $this->rememberResolved((string) $cacheKey, []) : [];
+ return [];
}
$applyAttribute = $plan['applyAttribute'];
@@ -133,7 +118,7 @@ public function resolve(
$attributeData,
);
if (!$paramsLeft) {
- return $useValueCache ? $this->rememberResolved((string) $cacheKey, $processed) : $processed;
+ return $processed;
}
[
@@ -151,7 +136,7 @@ public function resolve(
$processed = $this->processVariadic($processed, $variadic, $sort);
}
- return $useValueCache ? $this->rememberResolved((string) $cacheKey, $processed) : $processed;
+ return $processed;
}
/**
@@ -182,38 +167,53 @@ public function resolveByDefinitionType(string $name, ReflectionParameter $param
if ($this->repository->hasFunctionReference($typeName)) {
return $this->definitionResolver->resolve($typeName);
}
-
- if (class_exists($typeName) || interface_exists($typeName)) {
- $ref = ReflectionResource::getClassReflection($typeName);
-
- return $this->classResolver->resolve($ref)['instance'];
- }
}
return $this->stdClass;
}
- public function setClassResolverInstance(ClassResolver $classResolver): void
+ /**
+ * @param ReflectionClass $dependency
+ */
+ public function resolveContextualDependency(string $consumer, ReflectionClass $dependency): mixed
{
- $this->classResolver = $classResolver;
- }
+ if ($consumer === '') {
+ return $this->stdClass;
+ }
- private static function normalise(mixed $value): mixed
- {
- return match (true) {
- $value instanceof \Closure => 'closure#' . spl_object_id($value),
- is_object($value) => 'obj#' . spl_object_id($value),
- is_resource($value) => 'res#' . get_resource_type($value) . '#' . (int) $value,
- is_array($value) => array_map([self::class, 'normalise'], $value),
- default => $value,
- };
+ $binding = $this->repository->getContextualBinding($consumer, $dependency->getName());
+ if ($binding === null) {
+ return $this->stdClass;
+ }
+
+ if (is_callable($binding)) {
+ return $binding($this->repository->container());
+ }
+
+ if (is_string($binding)) {
+ if ($this->repository->hasFunctionReference($binding)) {
+ return $this->definitionResolver->resolve($binding);
+ }
+
+ if (class_exists($binding) || interface_exists($binding)) {
+ $resolvedClass = $this->applyEnvOverride($binding);
+
+ return $this->classResolver->resolveClassInstance(
+ ReflectionResource::getClassReflection($resolvedClass),
+ );
+ }
+ }
+
+ if (is_object($binding) && is_a($binding, $dependency->getName())) {
+ return $binding;
+ }
+
+ return $binding;
}
- private static function stableHash(string $value): string
+ public function setClassResolverInstance(ClassResolver $classResolver): void
{
- $algorithm = in_array('xxh128', hash_algos(), true) ? 'xxh128' : 'xxh3';
-
- return hash($algorithm, $value);
+ $this->classResolver = $classResolver;
}
/**
@@ -384,33 +384,6 @@ private function getResolvableReflection(
return $reflection;
}
- /**
- * @param array $supplied
- */
- private function makeFastScalarFingerprint(array $supplied): ?string
- {
- if (count($supplied) > 16) {
- return null;
- }
-
- $parts = [];
- foreach ($supplied as $key => $value) {
- if (is_array($value) || is_object($value) || is_resource($value)) {
- return null;
- }
-
- $parts[] = $key . '=' . match (true) {
- $value === null => 'n:null',
- is_bool($value) => 'b:' . ($value ? '1' : '0'),
- is_int($value) => 'i:' . $value,
- is_float($value) => 'f:' . $value,
- default => 's:' . base64_encode(is_string($value) ? $value : var_export($value, true)),
- };
- }
-
- return 'f:' . implode('|', $parts);
- }
-
private function makeParameterAttributePlanKey(ReflectionParameter $parameter): string
{
$function = $parameter->getDeclaringFunction();
@@ -420,30 +393,6 @@ private function makeParameterAttributePlanKey(ReflectionParameter $parameter):
return $owner . '::' . $function->getName() . '|p:' . $parameter->getPosition() . '|sig:' . $signature;
}
- /**
- * @param array $supplied
- */
- private function makeResolutionCacheKey(
- ReflectionFunctionAbstract $reflector,
- array $supplied,
- string $type,
- ): string {
- $owner = $this->ownerFor($reflector);
- if ($supplied === []) {
- return "$owner::{$reflector->getName()}|$type|empty";
- }
-
- $fast = $this->makeFastScalarFingerprint($supplied);
- if ($fast !== null) {
- return "$owner::{$reflector->getName()}|$type|$fast";
- }
-
- $norm = array_map([self::class, 'normalise'], $supplied);
- $argsHash = self::stableHash(json_encode($norm, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
-
- return "$owner::{$reflector->getName()}|$type|h:$argsHash";
- }
-
private function makeResolutionPlanKey(ReflectionFunctionAbstract $reflector, string $type): string
{
$owner = $this->ownerFor($reflector);
@@ -557,23 +506,4 @@ private function rememberResolutionPlan(string $key, array $value): array
return $value;
}
-
- /**
- * @param array $value
- * @return array
- */
- private function rememberResolved(string $key, array $value): array
- {
- $this->evictCacheKeyIfNeeded($this->resolvedCache, $key, self::RESOLUTION_CACHE_LIMIT);
- $this->resolvedCache[$key] = $value;
-
- return $value;
- }
-
- private function shouldUseValueCache(ReflectionFunctionAbstract $reflector, string $type): bool
- {
- return $type === 'constructor'
- && $reflector instanceof ReflectionMethod
- && $reflector->getName() === '__construct';
- }
}
diff --git a/src/DI/Resolver/Repository.php b/src/DI/Resolver/Repository.php
index a457053..5fd868c 100644
--- a/src/DI/Resolver/Repository.php
+++ b/src/DI/Resolver/Repository.php
@@ -31,15 +31,23 @@ class Repository
private ?DefinitionCachePoolInterface $cacheAdapter = null;
+ private bool $cacheRuntimeObjects = false;
+
/** @var array> */
private array $classResource = [];
/** @var array}> */
private array $closureResource = [];
+ /** @var array> */
+ private array $compiledResolvers = [];
+
/** @var array> */
private array $conditionalBindings = [];
+ /** @var array> */
+ private array $contextualBindings = [];
+
private string $currentScope = 'root';
private ?string $defaultMethod = null;
@@ -59,10 +67,21 @@ class Repository
/** @var array */
private array $functionReference = [];
+ private bool $hasHooks = false;
+
private bool $isLocked = false;
private bool $lazyLoading = true;
+ /** @var array> */
+ private array $onResolvedHooks = [];
+
+ /** @var array> */
+ private array $onResolvingHooks = [];
+
+ /** @var array> */
+ private array $onScopeLeaveHooks = [];
+
/** @var array */
private array $resolved = [];
@@ -84,6 +103,9 @@ class Repository
/** @var array>> */
private array $tagIndexByEnv = [];
+ /** @var array> */
+ private array $tagOverrideIdsByEnv = [];
+
/**
* Constructs a Repository instance.
*
@@ -91,7 +113,7 @@ class Repository
* and interactions within the container. This aids in debugging and
* tracing the service resolution process.
*/
- public function __construct(Container $container)
+ public function __construct(private readonly Container $container)
{
$this->tracer = new DebugTracer();
$this->attributeRegistry = new AttributeRegistry($container);
@@ -170,6 +192,33 @@ public function bindInterfaceForEnv(string $env, string $interface, string $conc
$this->conditionalBindings[$env][$interface] = $concrete;
}
+ public function container(): Container
+ {
+ return $this->container;
+ }
+
+ public function dispatchResolvedHooks(string $id, mixed $value): void
+ {
+ if (!$this->hasHooks) {
+ return;
+ }
+
+ foreach ($this->onResolvedHooks[$id] ?? [] as $hook) {
+ $hook($id, $value);
+ }
+ }
+
+ public function dispatchResolvingHooks(string $id): void
+ {
+ if (!$this->hasHooks) {
+ return;
+ }
+
+ foreach ($this->onResolvingHooks[$id] ?? [] as $hook) {
+ $hook($id);
+ }
+ }
+
/**
* Enables or disables lazy loading for the repository.
*
@@ -335,6 +384,16 @@ public function getClosureResourceEntry(string $alias): ?array
return $this->closureResource[$alias] ?? null;
}
+ public function getCompiledResolver(string $id): mixed
+ {
+ return $this->compiledResolvers[$id]['factory'] ?? null;
+ }
+
+ public function getContextualBinding(string $consumer, string $dependency): mixed
+ {
+ return $this->contextualBindings[$consumer][$dependency] ?? null;
+ }
+
/**
* Retrieves the default method to be called when resolving a class.
*
@@ -456,9 +515,9 @@ public function getIdsByTag(string $tag): array
$ids[$id] = true;
}
- $overrides = $this->definitionMetaByEnv[$env] ?? [];
- foreach ($overrides as $id => $override) {
- if (!array_key_exists('tags', $override)) {
+ foreach ($this->tagOverrideIdsByEnv[$env] ?? [] as $id => $_) {
+ $override = $this->definitionMetaByEnv[$env][$id] ?? null;
+ if (!is_array($override) || !array_key_exists('tags', $override)) {
continue;
}
@@ -614,8 +673,19 @@ public function isPropertyAttributeEnabled(): bool
return $this->enablePropertyAttribute;
}
+ public function isTracingEnabled(): bool
+ {
+ return $this->tracer->isEnabled();
+ }
+
public function leaveScope(): void
{
+ if ($this->hasHooks) {
+ foreach ($this->onScopeLeaveHooks[$this->currentScope] ?? [] as $hook) {
+ $hook($this->currentScope, $this->container);
+ }
+ }
+
$this->clearScopeResolvedEntries($this->currentScope);
$previous = array_pop($this->scopeStack);
$this->currentScope = is_string($previous) ? $previous : 'root';
@@ -647,6 +717,24 @@ public function makeCacheKey(string $suffix): string
return $this->alias . '-' . $suffix;
}
+ public function onResolved(string $id, callable $hook): void
+ {
+ $this->hasHooks = true;
+ $this->onResolvedHooks[$id][] = $hook;
+ }
+
+ public function onResolving(string $id, callable $hook): void
+ {
+ $this->hasHooks = true;
+ $this->onResolvingHooks[$id][] = $hook;
+ }
+
+ public function onScopeLeave(string $scope, callable $hook): void
+ {
+ $this->hasHooks = true;
+ $this->onScopeLeaveHooks[$scope][] = $hook;
+ }
+
/**
* Resets the current scope, removing all resolved resources that were
* created under the current scope.
@@ -699,6 +787,34 @@ public function setCacheAdapter(CacheItemPoolInterface $adapter): void
: new Psr6DefinitionCachePoolAdapter($adapter);
}
+ public function setCacheRuntimeObjects(bool $cacheRuntimeObjects): void
+ {
+ $this->checkIfLocked();
+ $this->cacheRuntimeObjects = $cacheRuntimeObjects;
+ }
+
+ /**
+ * @param array $resolvers
+ */
+ public function setCompiledResolvers(array $resolvers): void
+ {
+ $this->checkIfLocked();
+ $normalized = [];
+ foreach ($resolvers as $id => $resolver) {
+ if (!is_string($id) || !is_callable($resolver)) {
+ continue;
+ }
+ $normalized[$id] = ['factory' => $resolver];
+ }
+ $this->compiledResolvers = $normalized;
+ }
+
+ public function setContextualBinding(string $consumer, string $dependency, mixed $give): void
+ {
+ $this->checkIfLocked();
+ $this->contextualBindings[$consumer][$dependency] = $give;
+ }
+
/**
* Sets the default method to be called when resolving a class.
*
@@ -777,6 +893,7 @@ public function setDefinitionMetaForEnv(
if (array_key_exists('tags', $normalized)) {
$oldTags = $existing['tags'] ?? [];
$this->refreshEnvTagIndex($env, $id, $oldTags, $normalized['tags']);
+ $this->tagOverrideIdsByEnv[$env][$id] = true;
}
}
@@ -892,6 +1009,20 @@ public function setScope(string $scope): void
$this->currentScope = $scope;
}
+ public function shouldCacheRuntimeObjects(): bool
+ {
+ return $this->cacheRuntimeObjects;
+ }
+
+ public function shouldPersistDefinitionValue(mixed $value): bool
+ {
+ if ($this->cacheRuntimeObjects) {
+ return !is_resource($value) && !$value instanceof \Closure;
+ }
+
+ return $this->isSafeCachedDefinitionValue($value);
+ }
+
/**
* @return DebugTracer The debug tracer associated with this repository.
*
@@ -904,6 +1035,11 @@ public function tracer(): DebugTracer
return $this->tracer;
}
+ public function unsetResolvedResource(string $className): void
+ {
+ unset($this->resolvedResource[$className]);
+ }
+
/**
* Throw an exception if the container is locked and we try to set/modify values.
*
@@ -937,6 +1073,25 @@ private function clearScopeResolvedEntries(string $scope): void
}
}
+ private function isSafeCachedDefinitionValue(mixed $value): bool
+ {
+ if (is_scalar($value) || $value === null) {
+ return true;
+ }
+
+ if (!is_array($value)) {
+ return false;
+ }
+
+ foreach ($value as $item) {
+ if (!$this->isSafeCachedDefinitionValue($item)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
/**
* @param array $oldTags
* @param array $newTags
diff --git a/src/DI/Support/CompiledResolverGenerator.php b/src/DI/Support/CompiledResolverGenerator.php
new file mode 100644
index 0000000..5b233fc
--- /dev/null
+++ b/src/DI/Support/CompiledResolverGenerator.php
@@ -0,0 +1,137 @@
+
+ * @throws ReflectionException
+ */
+ public function generate(Container $container, string $filePath): array
+ {
+ $repo = $container->getRepository();
+ $definitions = $repo->getFunctionReference();
+ $compiledCodeEntries = [];
+
+ foreach ($definitions as $id => $definition) {
+ $compiledEntry = $this->compileEntry($definition);
+ if ($compiledEntry === null) {
+ continue;
+ }
+
+ $compiledCodeEntries[] = var_export($id, true) . ' => ' . $compiledEntry;
+ }
+
+ $code = " $result */
+ $result = require $filePath;
+
+ return $result;
+ }
+
+ /**
+ * @param array $definition
+ */
+ private function compileArrayDefinition(array $definition): ?string
+ {
+ if (!isset($definition[0]) || !is_string($definition[0]) || !class_exists($definition[0])) {
+ return null;
+ }
+
+ $class = '\\' . ltrim($definition[0], '\\');
+ $method = isset($definition[1]) && is_string($definition[1])
+ ? var_export($definition[1], true)
+ : 'false';
+
+ return "static fn(Container \$c): mixed => \$c->make({$class}::class, {$method})";
+ }
+
+ private function compileClassDefinition(string $definition): ?string
+ {
+ $class = ReflectionResource::getClassReflection($definition);
+ if (!$class->isInstantiable()) {
+ return null;
+ }
+
+ $constructor = $class->getConstructor();
+ if ($constructor === null || $constructor->getParameters() === []) {
+ $fqcn = '\\' . ltrim($definition, '\\');
+
+ return "static fn(Container \$c): mixed => new {$fqcn}()";
+ }
+
+ $arguments = $this->compileConstructorArguments($constructor->getParameters());
+ if ($arguments === null) {
+ return null;
+ }
+
+ $fqcn = '\\' . ltrim($definition, '\\');
+ $args = implode(', ', $arguments);
+
+ return "static fn(Container \$c): mixed => new {$fqcn}({$args})";
+ }
+
+ /**
+ * @param array $parameters
+ * @return array|null
+ */
+ private function compileConstructorArguments(array $parameters): ?array
+ {
+ $arguments = [];
+ foreach ($parameters as $parameter) {
+ if ($parameter->isVariadic()) {
+ return null;
+ }
+
+ $type = $parameter->getType();
+ if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
+ $arguments[] = '$c->get(\\' . ltrim($type->getName(), '\\') . '::class)';
+
+ continue;
+ }
+
+ if ($parameter->isDefaultValueAvailable()) {
+ $arguments[] = var_export($parameter->getDefaultValue(), true);
+
+ continue;
+ }
+
+ if ($parameter->allowsNull()) {
+ $arguments[] = 'null';
+
+ continue;
+ }
+
+ return null;
+ }
+
+ return $arguments;
+ }
+
+ private function compileEntry(mixed $definition): ?string
+ {
+ if (is_array($definition)) {
+ return $this->compileArrayDefinition($definition);
+ }
+
+ if (!is_string($definition) || !class_exists($definition)) {
+ return null;
+ }
+
+ return $this->compileClassDefinition($definition);
+ }
+}
diff --git a/src/DI/Support/ContextualBindingBuilder.php b/src/DI/Support/ContextualBindingBuilder.php
new file mode 100644
index 0000000..d091b1e
--- /dev/null
+++ b/src/DI/Support/ContextualBindingBuilder.php
@@ -0,0 +1,41 @@
+dependency === null || $this->dependency === '') {
+ throw new ContainerException('Contextual binding requires needs() before give(...).');
+ }
+
+ $this->container
+ ->getRepository()
+ ->setContextualBinding($this->consumer, $this->dependency, $implementation);
+
+ return $this->container;
+ }
+
+ public function needs(string $dependency): self
+ {
+ $this->dependency = $dependency;
+
+ return $this;
+ }
+}
diff --git a/src/DI/Support/DebugTracer.php b/src/DI/Support/DebugTracer.php
index f50fc25..d94c74b 100644
--- a/src/DI/Support/DebugTracer.php
+++ b/src/DI/Support/DebugTracer.php
@@ -170,6 +170,16 @@ public function getFormatted(callable $formatter): mixed
return $out;
}
+ public function isCaptureLocationEnabled(): bool
+ {
+ return $this->captureLocation;
+ }
+
+ public function isEnabled(): bool
+ {
+ return $this->enabled;
+ }
+
/* --------------------------------------------------------------------- */
public function level(): TraceLevelEnum
diff --git a/src/DI/Support/PendingFactoryBinding.php b/src/DI/Support/PendingFactoryBinding.php
new file mode 100644
index 0000000..e97bde9
--- /dev/null
+++ b/src/DI/Support/PendingFactoryBinding.php
@@ -0,0 +1,71 @@
+ $tags
+ * @throws ContainerException
+ */
+ public function register(array $tags = []): Container
+ {
+ return $this->singleton($tags);
+ }
+
+ /**
+ * @param array $tags
+ * @throws ContainerException
+ */
+ public function scoped(array $tags = []): Container
+ {
+ return $this->apply(LifetimeEnum::Scoped, $tags);
+ }
+
+ /**
+ * @param array $tags
+ * @throws ContainerException
+ */
+ public function singleton(array $tags = []): Container
+ {
+ return $this->apply(LifetimeEnum::Singleton, $tags);
+ }
+
+ /**
+ * @param array $tags
+ * @throws ContainerException
+ */
+ public function transient(array $tags = []): Container
+ {
+ return $this->apply(LifetimeEnum::Transient, $tags);
+ }
+
+ /**
+ * @param array $tags
+ * @throws ContainerException
+ */
+ private function apply(LifetimeEnum $lifetime, array $tags = []): Container
+ {
+ $factory = $this->factory;
+ $container = $this->container;
+
+ return $this->container->bind(
+ $this->id,
+ static fn() => $factory($container),
+ $lifetime,
+ $tags,
+ );
+ }
+}
diff --git a/src/DI/Support/ReflectionResource.php b/src/DI/Support/ReflectionResource.php
index 85711e7..6a11ad3 100644
--- a/src/DI/Support/ReflectionResource.php
+++ b/src/DI/Support/ReflectionResource.php
@@ -20,10 +20,12 @@
*/
final class ReflectionResource
{
+ private static int $cacheLimit = 0;
+
/**
* @var array{
- * classes: array>,
- * enums: array>,
+ * classes: array>,
+ * enums: array>,
* functions: array,
* methods: array
* }
@@ -35,6 +37,26 @@ final class ReflectionResource
'methods' => [],
];
+ /**
+ * @return array{limit:int,classes:int,enums:int,functions:int,methods:int,total:int}
+ */
+ public static function cacheStats(): array
+ {
+ $classes = count(self::$reflectionCache['classes']);
+ $enums = count(self::$reflectionCache['enums']);
+ $functions = count(self::$reflectionCache['functions']);
+ $methods = count(self::$reflectionCache['methods']);
+
+ return [
+ 'limit' => self::$cacheLimit,
+ 'classes' => $classes,
+ 'enums' => $enums,
+ 'functions' => $functions,
+ 'methods' => $methods,
+ 'total' => $classes + $enums + $functions + $methods,
+ ];
+ }
+
public static function clearCache(): void
{
self::$reflectionCache = [
@@ -46,7 +68,7 @@ public static function clearCache(): void
}
/**
- * @param callable|array{0: object|string, 1: string}|string|object $callable
+ * @param callable|array|string|object $callable
*
* @throws InvalidArgumentException|ReflectionException
*/
@@ -92,7 +114,11 @@ public static function getClassReflection(string|object $class): ReflectionClass
}
if (!isset(self::$reflectionCache['classes'][$className])) {
- self::$reflectionCache['classes'][$className] = new ReflectionClass($className);
+ self::rememberCachedEntry(
+ self::$reflectionCache['classes'],
+ $className,
+ new ReflectionClass($className),
+ );
}
return self::$reflectionCache['classes'][$className];
@@ -110,7 +136,11 @@ public static function getEnumReflection(string $enumName): ReflectionEnum
}
if (!isset(self::$reflectionCache['enums'][$enumName])) {
- self::$reflectionCache['enums'][$enumName] = new ReflectionEnum($enumName);
+ self::rememberCachedEntry(
+ self::$reflectionCache['enums'],
+ $enumName,
+ new ReflectionEnum($enumName),
+ );
}
return self::$reflectionCache['enums'][$enumName];
@@ -120,7 +150,11 @@ public static function getFunctionReflection(string|Closure $function): Reflecti
{
$key = is_string($function) ? $function : spl_object_hash($function);
if (!isset(self::$reflectionCache['functions'][$key])) {
- self::$reflectionCache['functions'][$key] = new ReflectionFunction($function);
+ self::rememberCachedEntry(
+ self::$reflectionCache['functions'],
+ $key,
+ new ReflectionFunction($function),
+ );
}
return self::$reflectionCache['functions'][$key];
@@ -173,6 +207,40 @@ public static function getSignature(
return base64_encode("$fileName:$startLine");
}
+ public static function setCacheLimit(int $limit): void
+ {
+ self::$cacheLimit = max(0, $limit);
+ self::trimAllCachesIfNeeded();
+ }
+
+ /**
+ * @template T
+ * @param array $cache
+ */
+ private static function evictOldestIfNeeded(array &$cache): void
+ {
+ if (self::$cacheLimit <= 0 || count($cache) < self::$cacheLimit) {
+ return;
+ }
+
+ $oldestKey = array_key_first($cache);
+ unset($cache[$oldestKey]);
+ }
+
+ /**
+ * @template T
+ * @param array $cache
+ * @param T $value
+ */
+ private static function rememberCachedEntry(array &$cache, string $key, mixed $value): void
+ {
+ if (!isset($cache[$key])) {
+ self::evictOldestIfNeeded($cache);
+ }
+
+ $cache[$key] = $value;
+ }
+
/**
* @param callable(): ReflectionMethod $resolver
*/
@@ -181,7 +249,7 @@ private static function rememberMethod(string $key, callable $resolver): Reflect
if (!isset(self::$reflectionCache['methods'][$key])) {
try {
$method = $resolver();
- self::$reflectionCache['methods'][$key] = $method;
+ self::rememberCachedEntry(self::$reflectionCache['methods'], $key, $method);
} catch (Throwable $exception) {
throw new InvalidArgumentException($exception->getMessage(), 0, $exception);
}
@@ -253,4 +321,24 @@ private static function resolveStringCallable(string $callable): ReflectionMetho
throw new InvalidArgumentException("Function or method '$callable' does not exist.");
}
+
+ private static function trimAllCachesIfNeeded(): void
+ {
+ self::trimBucket(self::$reflectionCache['classes']);
+ self::trimBucket(self::$reflectionCache['enums']);
+ self::trimBucket(self::$reflectionCache['functions']);
+ self::trimBucket(self::$reflectionCache['methods']);
+ }
+
+ /**
+ * @template T
+ * @param array $bucket
+ */
+ private static function trimBucket(array &$bucket): void
+ {
+ while (self::$cacheLimit > 0 && count($bucket) > self::$cacheLimit) {
+ $oldestKey = array_key_first($bucket);
+ unset($bucket[$oldestKey]);
+ }
+ }
}
diff --git a/src/DI/Support/TaggedPipeline.php b/src/DI/Support/TaggedPipeline.php
new file mode 100644
index 0000000..4f3b7cd
--- /dev/null
+++ b/src/DI/Support/TaggedPipeline.php
@@ -0,0 +1,54 @@
+passable = $passable;
+
+ return $this;
+ }
+
+ /**
+ * @throws ContainerException
+ */
+ public function thenReturn(): mixed
+ {
+ $current = $this->passable;
+ foreach ($this->container->findByTagLazy($this->tag) as $resolver) {
+ $service = $resolver();
+
+ if (is_callable($service)) {
+ $current = $service($current);
+
+ continue;
+ }
+
+ if (is_object($service) && method_exists($service, 'handle')) {
+ $current = $service->handle($current);
+
+ continue;
+ }
+
+ if (is_object($service) && method_exists($service, 'process')) {
+ $current = $service->process($current);
+ }
+ }
+
+ return $current;
+ }
+}
diff --git a/src/Fence/Fence.php b/src/Fence/Fence.php
index 49cbe9b..9b281be 100644
--- a/src/Fence/Fence.php
+++ b/src/Fence/Fence.php
@@ -11,32 +11,24 @@
trait Fence
{
- /** @var array|null */
- private static ?array $cachedClasses = null;
+ private const string SINGLE_SLOT = '__single';
- /** @var array|null */
- private static ?array $cachedExtensions = null;
+ /** @var array */
+ private static array $classExistsCache = [];
- /** @var array dynamic overrides of limits per class */
- private static array $classLimits = [];
-
- private static int $instanceCount = 0;
+ /** @var array */
+ private static array $extensionExistsCache = [];
/** @var array */
private static array $instances = [];
- /** @var array keyed flag cache */
- private static array $keyedCache = [];
-
- /** @var array default limits (before setLimit()) */
- private static array $limitCache = [];
+ private static ?int $limitOverride = null;
/**
* Resets the internal cache of instances. This is mostly useful for unit tests.
*/
final public static function clearInstances(): void
{
- self::$instanceCount = 0;
self::$instances = [];
}
@@ -47,7 +39,7 @@ final public static function clearInstances(): void
*/
final public static function countInstances(): int
{
- return self::$instanceCount;
+ return count(self::$instances);
}
/**
@@ -89,11 +81,7 @@ final public static function getKeys(): array
*/
final public static function hasInstance(?string $key = 'default'): bool
{
- $slot = self::isKeyed(static::class)
- ? ($key ?? 'default')
- : '__single';
-
- return isset(self::$instances[$slot]);
+ return isset(self::$instances[self::slotFor($key)]);
}
/**
@@ -112,25 +100,25 @@ final public static function instance(
?string $key = 'default',
?array $constraints = null,
): static {
- self::checkRequirements($constraints);
-
- $slot = self::isKeyed(static::class)
- ? ($key ?? 'default')
- : '__single';
+ $slot = self::slotFor($key);
// Fast path – instance already exists
- if (isset(self::$instances[$slot]) && self::$instances[$slot] instanceof static) {
- return self::$instances[$slot];
+ if (isset(self::$instances[$slot])) {
+ /** @var static $instance */
+ $instance = self::$instances[$slot];
+
+ return $instance;
}
- if (self::$instanceCount >= self::getLimit(static::class)) {
+ self::checkRequirements($constraints);
+
+ $limit = self::getLimit();
+ if (count(self::$instances) >= $limit) {
throw new LimitExceededException(
- 'Instance limit of ' . self::getLimit(static::class) . ' exceeded for ' . static::class,
+ 'Instance limit of ' . $limit . ' exceeded for ' . static::class,
);
}
- self::$instanceCount++;
-
$created = new static();
self::$instances[$slot] = $created;
@@ -147,7 +135,7 @@ final public static function setLimit(int $n): void
if ($n < 1) {
throw new InvalidArgumentException('Limit must be at least 1');
}
- self::$classLimits[static::class] = $n;
+ self::$limitOverride = $n;
}
/**
@@ -158,12 +146,10 @@ final public static function setLimit(int $n): void
*/
private static function checkRequirements(?array $c): void
{
- if (self::hasNoRequirements($c)) {
+ if ($c === null || (($c['extensions'] ?? []) === [] && ($c['classes'] ?? []) === [])) {
return;
}
- self::warmRequirementCaches();
-
$missingE = self::findMissingExtensions((array) ($c['extensions'] ?? []));
$missingC = self::findMissingClasses((array) ($c['classes'] ?? []));
@@ -182,7 +168,20 @@ private static function checkRequirements(?array $c): void
*/
private static function findMissingClasses(array $required): array
{
- return array_diff($required, self::$cachedClasses ?? []);
+ $missing = [];
+
+ foreach ($required as $class) {
+ if ($class === '') {
+ continue;
+ }
+
+ self::$classExistsCache[$class] ??= class_exists($class);
+ if (!self::$classExistsCache[$class]) {
+ $missing[] = $class;
+ }
+ }
+
+ return $missing;
}
/**
@@ -191,7 +190,21 @@ private static function findMissingClasses(array $required): array
*/
private static function findMissingExtensions(array $required): array
{
- return array_diff($required, self::$cachedExtensions ?? []);
+ $missing = [];
+
+ foreach ($required as $extension) {
+ if ($extension === '') {
+ continue;
+ }
+
+ $cacheKey = strtolower($extension);
+ self::$extensionExistsCache[$cacheKey] ??= extension_loaded($extension);
+ if (!self::$extensionExistsCache[$cacheKey]) {
+ $missing[] = $extension;
+ }
+ }
+
+ return $missing;
}
/**
@@ -212,66 +225,36 @@ private static function formatMissingRequirements(array $missingE, array $missin
}
/**
- * Returns the instance limit for the given class.
- *
- * If `$classLimits[$cls]` is set, it takes precedence. Otherwise, if
- * the class defines `FENCE_LIMIT`, that value is used. Otherwise,
- * the limit is infinite (`PHP_INT_MAX`).
+ * Returns the instance limit for the current class.
*/
- private static function getLimit(string $cls): int
+ private static function getLimit(): int
{
- if (isset(self::$classLimits[$cls])) {
- return self::$classLimits[$cls];
+ if (self::$limitOverride !== null) {
+ return self::$limitOverride;
}
- if (!defined("$cls::FENCE_LIMIT")) {
- self::$classLimits[$cls] = PHP_INT_MAX;
-
+ $className = static::class;
+ if (!defined("$className::FENCE_LIMIT")) {
return PHP_INT_MAX;
}
- $rawLimit = constant("$cls::FENCE_LIMIT");
- $limit = match (true) {
- is_int($rawLimit) => $rawLimit,
- is_string($rawLimit) && is_numeric($rawLimit) => (int) $rawLimit,
- is_float($rawLimit) => (int) $rawLimit,
- default => PHP_INT_MAX,
- };
- self::$classLimits[$cls] = $limit;
-
- return $limit;
+ return (int) constant("$className::FENCE_LIMIT");
}
- /**
- * @param array{extensions?: array, classes?: array}|null $c
- */
- private static function hasNoRequirements(?array $c): bool
+ private static function isKeyed(): bool
{
- if (!$c) {
- return true;
+ $className = static::class;
+ if (defined("$className::FENCE_KEYED")) {
+ return (bool) constant("$className::FENCE_KEYED");
}
- return ($c['extensions'] ?? []) === [] && ($c['classes'] ?? []) === [];
+ return true;
}
- /**
- * Check if the given class is keyed.
- *
- * If the class defines a constant `FENCE_KEYED`, its value is used.
- * Otherwise, the default is to be keyed (`true`).
- *
- * @param string $cls The class to check.
- * @return bool Whether the class is keyed.
- */
- private static function isKeyed(string $cls): bool
+ private static function slotFor(?string $key): string
{
- return self::$keyedCache[$cls]
- ??= !defined("$cls::FENCE_KEYED") || (bool) constant("$cls::FENCE_KEYED");
- }
-
- private static function warmRequirementCaches(): void
- {
- self::$cachedExtensions ??= get_loaded_extensions();
- self::$cachedClasses ??= get_declared_classes();
+ return self::isKeyed()
+ ? ($key ?? 'default')
+ : self::SINGLE_SLOT;
}
}
diff --git a/src/Remix/MacroMix.php b/src/Remix/MacroMix.php
index 40aab83..76b336b 100644
--- a/src/Remix/MacroMix.php
+++ b/src/Remix/MacroMix.php
@@ -13,6 +13,9 @@
trait MacroMix
{
+ /** @var array */
+ protected static array $macroIsStaticClosure = [];
+
/**
* @var array
*/
@@ -146,6 +149,13 @@ public static function loadMacrosFromConfig(array $config): void
public static function macro(string $name, callable|object $macro): void
{
static::$macros[$name] = $macro;
+ if ($macro instanceof Closure) {
+ static::$macroIsStaticClosure[$name] = (new ReflectionFunction($macro))->isStatic();
+
+ return;
+ }
+
+ unset(static::$macroIsStaticClosure[$name]);
}
/**
@@ -193,6 +203,7 @@ public static function mix(object|string $mixin, bool $replace = true): void
public static function removeMacro(string $name): void
{
unset(static::$macros[$name]);
+ unset(static::$macroIsStaticClosure[$name]);
}
/**
@@ -260,7 +271,10 @@ private static function process(?object $bind, string $method, array $parameters
$result = null;
$closure = $macro;
- if ($bind !== null && !(new ReflectionFunction($macro))->isStatic()) {
+ $isStaticClosure = static::$macroIsStaticClosure[$method]
+ ??= (new ReflectionFunction($macro))->isStatic();
+
+ if ($bind !== null && !$isStaticClosure) {
$bound = $macro->bindTo($bind, static::class);
$closure = $bound instanceof Closure ? $bound : $macro;
}
diff --git a/src/Serializer/SignedValueSerializer.php b/src/Serializer/SignedValueSerializer.php
new file mode 100644
index 0000000..3e5ceff
--- /dev/null
+++ b/src/Serializer/SignedValueSerializer.php
@@ -0,0 +1,34 @@
+key);
+
+ try {
+ return ValueSerializer::decode($payload, $base64);
+ } finally {
+ ValueSerializer::setPayloadSigningKey($previous);
+ }
+ }
+
+ public function encode(mixed $value, bool $base64 = true): string
+ {
+ $previous = ValueSerializer::currentPayloadSigningKey();
+ ValueSerializer::setPayloadSigningKey($this->key);
+
+ try {
+ return ValueSerializer::encode($value, $base64);
+ } finally {
+ ValueSerializer::setPayloadSigningKey($previous);
+ }
+ }
+}
diff --git a/src/Serializer/ValueSerializer.php b/src/Serializer/ValueSerializer.php
index 05ed744..9d61a72 100644
--- a/src/Serializer/ValueSerializer.php
+++ b/src/Serializer/ValueSerializer.php
@@ -36,6 +36,11 @@ public static function clearResourceHandlers(): void
self::$serializedClosureMemo = [];
}
+ public static function currentPayloadSigningKey(): ?string
+ {
+ return self::$payloadSigningKey;
+ }
+
/**
* Decode a payload produced by {@see encode()}.
*
@@ -178,6 +183,11 @@ public static function setPayloadSigningKey(?string $key): void
self::$serializedClosureMemo = [];
}
+ public static function signed(string $key): SignedValueSerializer
+ {
+ return new SignedValueSerializer($key);
+ }
+
/**
* Unserializes a given string into its original value.
*
diff --git a/src/functions.php b/src/functions.php
index 3ad738d..f5a99dc 100644
--- a/src/functions.php
+++ b/src/functions.php
@@ -24,7 +24,7 @@
*/
function container(
string|Closure|callable|array|null $closureOrClass = null,
- string $alias = __DIR__,
+ string $alias = Container::DEFAULT_ALIAS,
): mixed {
$instance = Container::instance($alias);
@@ -40,7 +40,7 @@ function container(
*
* @param string|Closure|callable|array|null $spec Closure|function|Class|[Class,method]|"Class@method"|"Class::method"
* @param array $parameters Parameters for constructor/method/closure
- * @param string $alias Optional container instance alias (default: __DIR__)
+ * @param string $alias Optional container instance alias (default: Container::DI_ALIAS)
* @return mixed Container instance (when $spec is null) or resolved return value
* @throws ContainerException|ReflectionException|InvalidArgumentException
* @param array{0:string,1:string}|null|string|Closure|callable $spec
@@ -48,7 +48,7 @@ function container(
function resolve(
string|Closure|callable|array|null $spec = null,
array $parameters = [],
- string $alias = __DIR__ . 'DI',
+ string $alias = Container::DI_ALIAS,
): mixed {
$instance = Container::instance($alias); // DI is on by default
@@ -64,7 +64,7 @@ function resolve(
*
* @param string|Closure|callable|array|null $spec Closure|function|Class|[Class,method]|"Class@method"|"Class::method"
* @param array $parameters Parameters for constructor/method/closure
- * @param string $alias Optional container instance alias (default: __DIR__)
+ * @param string $alias Optional container instance alias (default: Container::DIRECT_ALIAS)
* @return mixed Container instance (when $spec is null) or resolved return value
* @throws ContainerException|ReflectionException|InvalidArgumentException
* @param array{0:string,1:string}|null|string|Closure|callable $spec
@@ -72,7 +72,7 @@ function resolve(
function direct(
string|Closure|callable|array|null $spec = null,
array $parameters = [],
- string $alias = __DIR__ . 'DR',
+ string $alias = Container::DIRECT_ALIAS,
): mixed {
$instance = Container::instance($alias)
->options()
diff --git a/tests/Container/AdditionalContainerTest.php b/tests/Container/AdditionalContainerTest.php
index 236119a..a30a2d6 100644
--- a/tests/Container/AdditionalContainerTest.php
+++ b/tests/Container/AdditionalContainerTest.php
@@ -214,3 +214,24 @@ public function doSomething(BasicClass $basic, string $another): array
->end();
$container->call(InfuseUnknownParam::class, 'doSomething');
})->throws(ContainerException::class);
+
+test('helper default aliases are readable and isolated', function () {
+ $cDefault = container();
+ $cResolve = resolve();
+ $cDirect = direct();
+
+ expect($cDefault)->toBeInstanceOf(Container::class)
+ ->and($cResolve)->toBeInstanceOf(Container::class)
+ ->and($cDirect)->toBeInstanceOf(Container::class)
+ ->and($cDefault)->not->toBe($cResolve)
+ ->and($cResolve)->not->toBe($cDirect)
+ ->and($cDefault)->not->toBe($cDirect);
+});
+
+test('composer autoload files does not include global helpers by default', function () {
+ $composer = json_decode((string) file_get_contents(__DIR__ . '/../../composer.json'), true, 512, JSON_THROW_ON_ERROR);
+ $autoload = is_array($composer['autoload'] ?? null) ? $composer['autoload'] : [];
+ $files = is_array($autoload['files'] ?? null) ? $autoload['files'] : [];
+
+ expect($files)->not->toContain('src/functions.php');
+});
diff --git a/tests/Container/AdditionalTest.php b/tests/Container/AdditionalTest.php
index 61c311f..ba841a7 100644
--- a/tests/Container/AdditionalTest.php
+++ b/tests/Container/AdditionalTest.php
@@ -4,15 +4,201 @@
use Infocyph\InterMix\DI\Support\DebugTracer;
use Infocyph\InterMix\DI\Support\LifetimeEnum;
use Infocyph\InterMix\DI\Support\PreloadGenerator;
+use Infocyph\InterMix\DI\Support\ReflectionResource;
use Infocyph\InterMix\DI\Support\TraceLevelEnum;
+use Infocyph\InterMix\Exceptions\ContainerException;
use Infocyph\InterMix\Tests\Fixture\DemoProvider;
use Infocyph\InterMix\Tests\Fixture\DummyLogger;
use Infocyph\InterMix\Tests\Fixture\FooService;
-use Infocyph\InterMix\Tests\Fixture\ListenerA;
-use Infocyph\InterMix\Tests\Fixture\ListenerB;
use Infocyph\InterMix\Tests\Fixture\PaymentGateway;
use Infocyph\InterMix\Tests\Fixture\PaypalGateway;
use Infocyph\InterMix\Tests\Fixture\StripeGateway;
+use Psr\Cache\CacheItemInterface;
+use Psr\Cache\CacheItemPoolInterface;
+
+class FailingMakeTarget
+{
+ public function required(string $value): string
+ {
+ return $value;
+ }
+}
+
+interface ContextLogger
+{
+ public function channel(): string;
+}
+
+class OrderLogger implements ContextLogger
+{
+ public function channel(): string
+ {
+ return 'order';
+ }
+}
+
+class BillingLogger implements ContextLogger
+{
+ public function channel(): string
+ {
+ return 'billing';
+ }
+}
+
+class OrderService
+{
+ public function __construct(public ContextLogger $logger) {}
+}
+
+class BillingService
+{
+ public function __construct(public ContextLogger $logger) {}
+}
+
+class LazyTaggedProbe
+{
+ public function __construct(public int $id) {}
+}
+
+class TagEvent {}
+
+class TaggedListener
+{
+ public function __construct(public string $name) {}
+
+ public function handle(object $event): string
+ {
+ return $this->name . ':' . $event::class;
+ }
+}
+
+class CompiledDep {}
+
+class CompiledSvc
+{
+ public function __construct(public CompiledDep $dep) {}
+}
+
+class SpyCacheItem implements CacheItemInterface
+{
+ public function __construct(
+ private readonly string $key,
+ private mixed $value = null,
+ private bool $hit = false,
+ ) {}
+
+ public function getKey(): string
+ {
+ return $this->key;
+ }
+
+ public function get(): mixed
+ {
+ return $this->value;
+ }
+
+ public function isHit(): bool
+ {
+ return $this->hit;
+ }
+
+ public function set(mixed $value): static
+ {
+ $this->value = $value;
+
+ return $this;
+ }
+
+ public function expiresAfter(int|\DateInterval|null $time): static
+ {
+ return $this;
+ }
+
+ public function expiresAt(?\DateTimeInterface $expiration): static
+ {
+ return $this;
+ }
+}
+
+class SpyCachePool implements CacheItemPoolInterface
+{
+ /** @var array */
+ public array $items = [];
+
+ /** @var array */
+ public array $savedValues = [];
+
+ public function getItem(string $key): CacheItemInterface
+ {
+ if (!isset($this->items[$key])) {
+ $this->items[$key] = new SpyCacheItem($key, null, false);
+ }
+
+ return $this->items[$key];
+ }
+
+ public function getItems(array $keys = []): iterable
+ {
+ $items = [];
+ foreach ($keys as $key) {
+ if (is_string($key)) {
+ $items[$key] = $this->getItem($key);
+ }
+ }
+
+ return $items;
+ }
+
+ public function hasItem(string $key): bool
+ {
+ return isset($this->items[$key]) && $this->items[$key]->isHit();
+ }
+
+ public function clear(): bool
+ {
+ $this->items = [];
+ $this->savedValues = [];
+
+ return true;
+ }
+
+ public function deleteItem(string $key): bool
+ {
+ unset($this->items[$key]);
+
+ return true;
+ }
+
+ public function deleteItems(array $keys): bool
+ {
+ foreach ($keys as $key) {
+ if (is_string($key)) {
+ unset($this->items[$key]);
+ }
+ }
+
+ return true;
+ }
+
+ public function save(CacheItemInterface $item): bool
+ {
+ $stored = new SpyCacheItem($item->getKey(), $item->get(), true);
+ $this->items[$item->getKey()] = $stored;
+ $this->savedValues[] = $item->get();
+
+ return true;
+ }
+
+ public function saveDeferred(CacheItemInterface $item): bool
+ {
+ return $this->save($item);
+ }
+
+ public function commit(): bool
+ {
+ return true;
+ }
+}
it('collects a readable trace', function () {
$c = Container::instance('intermix')->options()->enableDebugTracing();
@@ -73,6 +259,80 @@
expect($first)->not->toBe($second);
});
+it('stores resolved entries only through lifetime-aware keys', function () {
+ $c = Container::instance(uniqid('lifetime_keys_'));
+ $repo = $c->getRepository();
+
+ $c->definitions()->bind('svc.singleton', fn () => new stdClass(), LifetimeEnum::Singleton);
+ $c->definitions()->bind('svc.scoped', fn () => new stdClass(), LifetimeEnum::Scoped);
+ $c->definitions()->bind('svc.transient', fn () => new stdClass(), LifetimeEnum::Transient);
+
+ $c->enterScope('request-a');
+ $c->get('svc.singleton');
+ $c->get('svc.scoped');
+ $c->get('svc.transient');
+
+ expect($repo->hasResolved('svc.singleton'))->toBeTrue()
+ ->and($repo->hasResolved('svc.scoped'))->toBeFalse()
+ ->and($repo->hasResolved('svc.scoped@request-a'))->toBeTrue()
+ ->and($repo->hasResolved('svc.transient'))->toBeFalse()
+ ->and($repo->hasResolved('svc.transient@request-a'))->toBeFalse();
+
+ $c->leaveScope();
+});
+
+it('reads scoped getReturn values from the current scope key', function () {
+ $c = Container::instance(uniqid('scope_return_'));
+ $repo = $c->getRepository();
+ $c->definitions()->bind('scope.return', fn () => new stdClass(), LifetimeEnum::Scoped);
+ $repo->setResolved('scope.return', ['instance' => new stdClass(), 'returned' => 'stale-base']);
+
+ $repo->setScope('request-1');
+ $repo->setResolvedScoped('request-1', 'scope.return@request-1', ['instance' => new stdClass(), 'returned' => 'scope-1']);
+ $first = $c->getReturn('scope.return');
+
+ $repo->setScope('request-2');
+ $repo->setResolvedScoped('request-2', 'scope.return@request-2', ['instance' => new stdClass(), 'returned' => 'scope-2']);
+ $second = $c->getReturn('scope.return');
+
+ expect($first)->toBe('scope-1')
+ ->and($second)->toBe('scope-2');
+});
+
+it('routes call() definition IDs through lifetime-aware get()', function () {
+ $c = Container::instance(uniqid('call_lifetime_'));
+ $c->definitions()->bind('svc.scoped', fn () => new stdClass(), LifetimeEnum::Scoped);
+ $c->definitions()->bind('svc.transient', fn () => new stdClass(), LifetimeEnum::Transient);
+
+ $c->enterScope('request-a');
+ $firstScoped = $c->call('svc.scoped');
+ $secondScoped = $c->call('svc.scoped');
+ $firstTransient = $c->call('svc.transient');
+ $secondTransient = $c->call('svc.transient');
+
+ expect($firstScoped)->toBe($secondScoped)
+ ->and($firstTransient)->not->toBe($secondTransient)
+ ->and($c->getRepository()->hasResolved('svc.scoped@request-a'))->toBeTrue()
+ ->and($c->getRepository()->hasResolved('svc.transient@request-a'))->toBeFalse();
+});
+
+it('factory binding is deferred until lifetime selection', function () {
+ $c = Container::instance(uniqid('factory_deferred_'));
+ $repo = $c->getRepository();
+
+ $pending = $c->factory(
+ 'factory.db',
+ static fn(Container $container): stdClass => $container->get(stdClass::class),
+ );
+
+ expect($repo->hasFunctionReference('factory.db'))->toBeFalse();
+
+ $pending->scoped();
+
+ expect($repo->hasFunctionReference('factory.db'))->toBeTrue()
+ ->and($repo->getDefinitionLifetime('factory.db'))->toBe(LifetimeEnum::Scoped);
+});
+
it('offers the same sugar directly on DefinitionManager', function () {
$c = Container::instance(uniqid('mp_'));
$def = $c->definitions();
@@ -157,16 +417,38 @@
->and($c->cfg)->toBe($c('cfg'));
});
-it('collects services by tag', function () {
- $c = Container::instance('intermix');
+it('resolves findByTag() eagerly and tagged() lazily', function () {
+ $c = Container::instance(uniqid('tag_modes_'));
+ $eagerBuilt = 0;
+ $lazyBuilt = 0;
+ $event = new TagEvent();
+
+ $c->definitions()->bind('event.eager', function () use (&$eagerBuilt) {
+ $eagerBuilt++;
- $c->definitions()->bind('A', fn () => new ListenerA(), tags: ['event']);
- $c->definitions()->bind('B', fn () => new ListenerB(), tags: ['event']);
+ return new TaggedListener('eager');
+ }, tags: ['event.eager']);
+ $c->definitions()->bind('event.lazy', function () use (&$lazyBuilt) {
+ $lazyBuilt++;
- $all = $c->findByTag('event'); // method defined in Container :contentReference[oaicite:0]{index=0}
- expect($all)->toHaveCount(2)
- ->and($all['A']())->toBe('A')
- ->and($all['B']())->toBe('B');
+ return new TaggedListener('lazy');
+ }, tags: ['event.lazy']);
+
+ $resolved = $c->findByTag('event.eager');
+ expect($eagerBuilt)->toBe(1)
+ ->and($resolved['event.eager'])->toBeInstanceOf(TaggedListener::class)
+ ->and($resolved['event.eager']->handle($event))->toBe('eager:' . TagEvent::class);
+
+ $lazyResolvers = $c->tagged('event.lazy');
+ expect($lazyBuilt)->toBe(0);
+
+ foreach ($lazyResolvers as $factory) {
+ $listener = $factory();
+ expect($listener)->toBeInstanceOf(TaggedListener::class)
+ ->and($listener->handle($event))->toBe('lazy:' . TagEvent::class);
+ }
+
+ expect($lazyBuilt)->toBe(1);
});
it('lets me wire and use services in one-liners', function () {
@@ -310,6 +592,181 @@
->and($empty['edges'])->toBe([]);
});
+it('restores tracer configuration after debug inspection', function () {
+ $c = Container::instance(uniqid('debug_restore_'));
+ $tracer = $c->tracer();
+ $tracer->setLevel(TraceLevelEnum::Off);
+ $tracer->setCaptureLocation(false);
+
+ $c->debug('missing.service');
+
+ expect($tracer->level())->toBe(TraceLevelEnum::Off)
+ ->and($tracer->isCaptureLocationEnabled())->toBeFalse();
+});
+
+it('does not leak resolved-resource state when make() fails', function () {
+ $c = Container::instance(uniqid('make_leak_'));
+
+ expect(fn () => $c->make(FailingMakeTarget::class, 'required'))
+ ->toThrow(ContainerException::class);
+
+ $resolved = $c->getRepository()->getResolvedResource();
+
+ expect(array_key_exists(FailingMakeTarget::class, $resolved))->toBeFalse()
+ ->and($c->getRepository()->getResolvedResourceFor(FailingMakeTarget::class))->toBe([]);
+});
+
+it('allows class-string self binding and rejects scalar self aliases', function () {
+ $c = Container::instance(uniqid('self_bind_'));
+ $c->definitions()->bind(CompiledDep::class, CompiledDep::class);
+
+ expect($c->get(CompiledDep::class))->toBeInstanceOf(CompiledDep::class);
+
+ expect(fn () => $c->definitions()->bind('foo', 'foo'))
+ ->toThrow(ContainerException::class);
+});
+
+it('does not persist runtime objects to PSR-6 definition cache by default', function () {
+ $pool = new SpyCachePool();
+ $c = Container::instance(uniqid('cache_safe_'));
+ $c->definitions()->enableDefinitionCache($pool);
+ $c->definitions()->bind('unsafe.obj', fn () => new stdClass());
+ $c->definitions()->bind('safe.cfg', ['a' => 1, 'b' => ['c' => 2]]);
+
+ $c->get('unsafe.obj');
+ $c->get('safe.cfg');
+
+ expect($pool->savedValues)->toHaveCount(1)
+ ->and($pool->savedValues[0])->toBe(['a' => 1, 'b' => ['c' => 2]]);
+});
+
+it('validates callable parsing for malformed method strings', function () {
+ $c = Container::instance(uniqid('callable_parse_'));
+
+ expect(fn () => $c->parseCallable('Foo@'))->toThrow(ContainerException::class);
+ expect(fn () => $c->parseCallable('@bar'))->toThrow(ContainerException::class);
+ expect(fn () => $c->parseCallable('Foo::'))->toThrow(ContainerException::class);
+ expect(fn () => $c->parseCallable('::bar'))->toThrow(ContainerException::class);
+});
+
+it('validates class and method existence in callable parsing', function () {
+ $c = Container::instance(uniqid('callable_exists_'));
+
+ expect(fn () => $c->parseCallable('Missing\\CallableClass@handle'))
+ ->toThrow(ContainerException::class, 'does not exist');
+
+ expect(fn () => $c->parseCallable(CompiledSvc::class . '::missingMethod'))
+ ->toThrow(ContainerException::class, 'does not exist');
+});
+
+it('supports contextual binding per consumer', function () {
+ $c = Container::instance(uniqid('contextual_'));
+ $c->when(OrderService::class)->needs(ContextLogger::class)->give(OrderLogger::class);
+ $c->when(BillingService::class)->needs(ContextLogger::class)->give(BillingLogger::class);
+
+ $order = $c->make(OrderService::class);
+ $billing = $c->make(BillingService::class);
+
+ expect($order->logger->channel())->toBe('order')
+ ->and($billing->logger->channel())->toBe('billing');
+});
+
+it('runs lifecycle hooks for resolving and resolved services', function () {
+ $c = Container::instance(uniqid('hooks_'));
+ $events = [];
+
+ $c->onResolving('hook.svc', function (string $id) use (&$events): void {
+ $events[] = "resolving:$id";
+ });
+ $c->onResolved('hook.svc', function (string $id) use (&$events): void {
+ $events[] = "resolved:$id";
+ });
+ $c->definitions()->bind('hook.svc', fn () => new stdClass(), LifetimeEnum::Transient);
+
+ $c->get('hook.svc');
+
+ expect($events)->toBe(['resolving:hook.svc', 'resolved:hook.svc']);
+});
+
+it('runs scope-leave lifecycle hooks', function () {
+ $c = Container::instance(uniqid('hook_scope_'));
+ $events = [];
+
+ $c->onScopeLeave('request', function (string $scope) use (&$events): void {
+ $events[] = "left:$scope";
+ });
+
+ $c->enterScope('request');
+ $c->leaveScope();
+
+ expect($events)->toBe(['left:request']);
+});
+
+it('returns lazy tagged resolvers without eager instantiation', function () {
+ $c = Container::instance(uniqid('tag_lazy_'));
+ $built = 0;
+ $c->definitions()->bind('lazy.a', function () use (&$built) {
+ $built++;
+
+ return new LazyTaggedProbe(1);
+ }, tags: ['lazy.tag']);
+
+ $lazy = $c->tagged('lazy.tag');
+ expect($built)->toBe(0);
+
+ foreach ($lazy as $factory) {
+ $svc = $factory();
+ expect($svc)->toBeInstanceOf(LazyTaggedProbe::class);
+ }
+
+ expect($built)->toBe(1);
+});
+
+it('can generate and load compiled resolver maps', function () {
+ $path = sys_get_temp_dir() . '/intermix_compiled_' . uniqid() . '.php';
+
+ $source = Container::instance(uniqid('compiled_src_'));
+ $source->definitions()->bind('compiled.dep', CompiledDep::class);
+ $source->definitions()->bind('compiled.svc', CompiledSvc::class);
+ $source->compileTo($path);
+ expect($source->getRepository()->getCompiledResolver('compiled.svc'))->toBeNull();
+
+ $source->compileTo($path, load: true);
+ expect($source->getRepository()->getCompiledResolver('compiled.svc'))->toBeCallable();
+
+ $target = Container::instance(uniqid('compiled_tgt_'));
+ $target->definitions()->bind('compiled.dep', CompiledDep::class);
+ $target->definitions()->bind('compiled.svc', CompiledSvc::class);
+ $target->useCompiled($path);
+
+ expect($target->get('compiled.svc'))->toBeInstanceOf(CompiledSvc::class);
+});
+
+it('supports bounded reflection cache controls', function () {
+ ReflectionResource::clearCache();
+ ReflectionResource::setCacheLimit(1);
+
+ ReflectionResource::getClassReflection(stdClass::class);
+ ReflectionResource::getClassReflection(DateTimeImmutable::class);
+ $stats = ReflectionResource::cacheStats();
+
+ expect($stats['classes'])->toBeLessThanOrEqual(1)
+ ->and($stats['limit'])->toBe(1);
+
+ ReflectionResource::setCacheLimit(0);
+ ReflectionResource::clearCache();
+});
+
+it('provides explicit container validation output', function () {
+ $c = Container::instance(uniqid('validate_'));
+ $c->definitions()->bind('broken.target', 'Missing\\NotFoundClass');
+
+ $issues = $c->validate();
+
+ expect($issues)->not()->toBeEmpty();
+ expect(fn () => $c->validate(strict: true))->toThrow(ContainerException::class);
+});
+
function newTracer(
TraceLevelEnum $level = TraceLevelEnum::Node,
bool $captureLocation = false,
diff --git a/tests/Container/InvokerTest.php b/tests/Container/InvokerTest.php
index 4150119..16cb20c 100644
--- a/tests/Container/InvokerTest.php
+++ b/tests/Container/InvokerTest.php
@@ -164,6 +164,16 @@ public function __construct()
}
}
+class CachedCallableService
+{
+ public function __construct(private readonly DateTimeZone $timezone) {}
+
+ public function __invoke(): string
+ {
+ return $this->timezone->getName();
+ }
+}
+
/* 6. Invoker should autowire an argument-less class ----------------*/
it('invokes a closure and autowires a Request instance', function () {
$out = $this->inv->invoke(
@@ -173,3 +183,31 @@ public function __construct()
// just assert we got **some** non-empty UUID back
expect($out)->toBeString()->not->toBe('')->toStartWith('req_');
});
+
+it('isolates callableFor cache per container', function () {
+ $c1 = Container::instance(uniqid('invoker_cache_a_'));
+ $c1->definitions()->bind(DateTimeZone::class, fn () => new DateTimeZone('UTC'));
+
+ $c2 = Container::instance(uniqid('invoker_cache_b_'));
+ $c2->definitions()->bind(DateTimeZone::class, fn () => new DateTimeZone('Asia/Dhaka'));
+
+ $inv1 = Invoker::with($c1);
+ $inv2 = Invoker::with($c2);
+
+ $f1 = $inv1->callableFor(CachedCallableService::class);
+ $f2 = $inv2->callableFor(CachedCallableService::class);
+
+ expect($f1())->toBe('UTC')
+ ->and($f2())->toBe('Asia/Dhaka');
+});
+
+it('invokes closures directly without storing closure aliases', function () {
+ $before = count($this->c->getRepository()->getClosureResource());
+
+ for ($i = 0; $i < 5; $i++) {
+ expect($this->inv->invoke(fn () => 'ok'))->toBe('ok');
+ }
+
+ $after = count($this->c->getRepository()->getClosureResource());
+ expect($after)->toBe($before);
+});
diff --git a/tests/Fence/FenceTraitTest.php b/tests/Fence/FenceTraitTest.php
index 99f15a6..e1d04f5 100644
--- a/tests/Fence/FenceTraitTest.php
+++ b/tests/Fence/FenceTraitTest.php
@@ -43,6 +43,17 @@ class DummyFence
->not->toThrow(RequirementException::class); // 'json' is typically loaded
});
+test('existing instance retrieval bypasses requirement checks', function () {
+ $first = DummyFence::instance('cached');
+
+ $second = DummyFence::instance('cached', [
+ 'extensions' => ['nonexistent_ext'],
+ 'classes' => ['NonExistentClass'],
+ ]);
+
+ expect($second)->toBe($first);
+});
+
test('introspection methods work', function () {
DummyFence::instance('one');
DummyFence::instance('two');
diff --git a/tests/Fixture/MethodAttrResolver.php b/tests/Fixture/MethodAttrResolver.php
index 08b5e52..fd6d779 100644
--- a/tests/Fixture/MethodAttrResolver.php
+++ b/tests/Fixture/MethodAttrResolver.php
@@ -12,7 +12,7 @@ public function resolve(
object $attributeInstance,
Reflector $target,
Container $container
- ): mixed {
+ ): null {
// Log, side-effect, or simply acknowledge
fwrite(
STDERR,
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 0000000..00463d5
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,6 @@
+