diff --git a/.claude/commands/check.md b/.claude/commands/check.md
new file mode 100644
index 0000000..22df204
--- /dev/null
+++ b/.claude/commands/check.md
@@ -0,0 +1,17 @@
+---
+description: Run the full quality suite and fix everything that fails
+allowed-tools: Bash, Read, Edit, Grep, Glob
+---
+
+Run `composer test-all` (php-cs-fixer dry-run, Psalm, PHPStan, PHPUnit, Rector dry-run).
+
+If anything fails:
+
+1. Fix the root cause in the source, not by widening ignores in `phpstan.neon` / `psalm.xml`.
+2. Auto-fixable style/Rector diffs: run `composer fix`, then review the diff.
+3. PHPStan OOM inside `resultCache.php` = stale cache -> `vendor/bin/phpstan clear-result-cache`.
+4. Re-run `composer test-all` until green.
+
+Report: what failed, what you changed, final suite status. Do not commit unless asked.
+
+$ARGUMENTS
diff --git a/.claude/commands/module.md b/.claude/commands/module.md
new file mode 100644
index 0000000..0bc9bf9
--- /dev/null
+++ b/.claude/commands/module.md
@@ -0,0 +1,23 @@
+---
+description: Scaffold a new Gacela module following this repo's layout
+allowed-tools: Bash, Read, Write, Edit, Grep, Glob
+argument-hint: [what it does]
+---
+
+Create module `$1` in `src/$1/`, mirroring `src/Invoice/`:
+
+- `$1Facade.php` — `/** @extends AbstractFacade<$1Factory> */`, thin delegation only.
+- `$1Factory.php` — `/** @extends AbstractFactory<$1Config> */`, builds Application/Domain objects.
+- `$1Config.php` — extends `AbstractConfig`, typed getters over `$this->get(...)`.
+- `$1DependencyProvider.php` — only if external deps are needed;
+ `/** @extends AbstractProvider<$1Config> */`, constants as `public const string`.
+- `Application/`, `Domain/`, `Infrastructure/` as needed; Domain talks to interfaces only.
+
+Rules: `declare(strict_types=1);`, `final` classes, `readonly` where stateless, DTOs in
+`src/Shared/Transfer/` with the `Transfer` suffix. Cross-module access goes through the
+other module's Facade.
+
+Add unit tests under `tests/Unit/$1/` mirroring the source paths; add a Feature test only
+if the module needs Gacela bootstrapping. Finish with `composer test-all`.
+
+Context: $ARGUMENTS
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..19a728d
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,55 @@
+{
+ "$schema": "https://json.schemastore.org/claude-code-settings.json",
+ "includeCoAuthoredBy": false,
+ "env": {
+ "XDEBUG_MODE": "off"
+ },
+ "permissions": {
+ "allow": [
+ "Bash(composer test:*)",
+ "Bash(composer test-all:*)",
+ "Bash(composer quality:*)",
+ "Bash(composer phpunit:*)",
+ "Bash(composer psalm:*)",
+ "Bash(composer phpstan:*)",
+ "Bash(composer csrun:*)",
+ "Bash(composer csfix:*)",
+ "Bash(composer fix:*)",
+ "Bash(composer rector:*)",
+ "Bash(composer ctal:*)",
+ "Bash(composer static-clear-cache:*)",
+ "Bash(composer test-coverage:*)",
+ "Bash(composer validate:*)",
+ "Bash(composer outdated:*)",
+ "Bash(composer show:*)",
+ "Bash(composer dump-autoload:*)",
+ "Bash(vendor/bin/phpunit:*)",
+ "Bash(vendor/bin/psalm:*)",
+ "Bash(vendor/bin/phpstan:*)",
+ "Bash(vendor/bin/php-cs-fixer:*)",
+ "Bash(vendor/bin/rector:*)",
+ "Bash(php -v)",
+ "Bash(php -r:*)",
+ "Bash(git status:*)",
+ "Bash(git diff:*)",
+ "Bash(git log:*)",
+ "Bash(git show:*)",
+ "Bash(gh pr view:*)",
+ "Bash(gh pr diff:*)",
+ "Bash(gh issue view:*)"
+ ],
+ "ask": [
+ "Bash(composer update:*)",
+ "Bash(composer require:*)",
+ "Bash(composer remove:*)",
+ "Bash(git push:*)",
+ "Bash(gh pr create:*)"
+ ],
+ "deny": [
+ "Read(./lightning-config.php)",
+ "Read(./nostr.json)",
+ "Read(./.env)",
+ "Read(./.env.*)"
+ ]
+ }
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1e94027..b8fff73 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,7 +18,7 @@ jobs:
- uses: shivammathur/setup-php@v2
with:
- php-version: 8.2
+ php-version: 8.3
coverage: none
tools: composer
@@ -47,7 +47,7 @@ jobs:
- uses: shivammathur/setup-php@v2
with:
- php-version: 8.2
+ php-version: 8.3
coverage: none
tools: composer
@@ -78,7 +78,7 @@ jobs:
fail-fast: false
matrix:
dependencies: [ "locked", "highest" ]
- php-version: [ "8.2" , "8.2", "8.3" ]
+ php-version: [ "8.3", "8.4", "8.5" ]
operating-system:
- "ubuntu-latest"
steps:
diff --git a/.scrutinizer.yml b/.scrutinizer.yml
index 8eda383..941702a 100644
--- a/.scrutinizer.yml
+++ b/.scrutinizer.yml
@@ -1,6 +1,6 @@
build:
environment:
- php: 8.2
+ php: 8.3
nodes:
analysis:
project_setup:
@@ -14,7 +14,7 @@ build:
tests:
override:
-
- # xdebug is installed but mode=off; PHPUnit 10 exits 1 when coverage
+ # xdebug is installed but mode=off; PHPUnit exits 1 when coverage
# is requested without a driver, so enable it explicitly.
command: 'XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-clover=coverage.clover --configuration phpunit.xml'
coverage:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f55685..27c95d7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@ All notable changes to this project are documented in this file.
## [Unreleased]
+- **BREAKING**: require PHP >= 8.3 (PHPUnit 12 and the Rector php83 set need it)
+- chore: update dependencies to their latest majors — gacela 1.21, PHPUnit 12.5,
+ Rector 2.5, php-cs-fixer 3.95, Psalm 6.16, symfony/http-client 7.4
+- chore: run CI on PHP 8.3, 8.4 and 8.5 (the matrix listed 8.2 twice)
+- docs: split the README into `docs/` (getting started, configuration, HTTP API,
+ architecture, development)
+
**Full Changelog**: https://github.com/php-lightning/lnaddress/compare/0.9.0...main
## [0.9.0] - 2026-07-23
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..5de7c9d
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,78 @@
+# lnaddress
+
+PHP library serving a [Lightning Address](https://lightningaddress.com/) endpoint
+(LNURL-pay / LUD-06). Built on [Gacela](https://gacela-project.com) (modular monolith) +
+`gacela-project/router`. PHP >= 8.3, PSR-4 `PhpLightning\` -> `src/`,
+`PhpLightningTest\` -> `tests/`.
+
+Full docs live in [`docs/`](docs/) — architecture, config reference, HTTP API.
+
+## Request flow
+
+`public/index.php` bootstraps Gacela (`gacela.php`) -> `Router` -> `InvoiceRoutesPlugin`
+(route + `CorsMiddleware` + `InvoiceExceptionHandler`) -> `InvoiceController` ->
+`InvoiceFacade`.
+
+- `GET /{username}` without `amount` -> `getCallbackUrl()` -> LNURL-pay params
+- `GET /{username}?amount=` -> `generateInvoice()` -> bolt11 under `pr`
+- Errors bubble to `InvoiceExceptionHandler`, which returns `{status: ERROR, reason: …}` —
+ controllers do not try/catch.
+
+Config comes from `lightning-config.dist.php` (overridable by gitignored
+`lightning-config.php`) plus users/backends in `backends.json`.
+
+## Module layout (Gacela)
+
+Modules live in `src//`. A module's public API is its **Facade** — never reach
+into another module's Application/Domain/Infrastructure.
+
+```
+src/Invoice/
+ InvoiceFacade.php entry point; @extends AbstractFacade
+ InvoiceFactory.php wiring; @extends AbstractFactory
+ InvoiceConfig.php typed config reads
+ InvoiceDependencyProvider.php external deps (HTTP_API)
+ Application/ use cases: CallbackUrl, InvoiceGenerator
+ Domain/ pure logic + interfaces (BackendInvoice, CallbackUrl, Http)
+ Infrastructure/ Controller, Handler, Middleware, Http, Plugin
+src/Config/ LightningConfig builder, BackendsConfig, BackendType enum
+src/Shared/ ConfigKey, Transfer DTOs, Value objects
+```
+
+Gacela generics matter: `AbstractFacade`/`AbstractFactory`/`AbstractProvider` are
+templated, so annotate with `@extends ...`. For docblock service resolution use
+`ServiceResolverAwareTrait` (`DocBlockResolverAwareTrait` is deprecated).
+
+Config keys are constants in `PhpLightning\Shared\Config\ConfigKey`, shared by the writer
+(`LightningConfig`) and reader (`InvoiceConfig`) — add new keys there, not as literals.
+
+## Conventions
+
+- `declare(strict_types=1);` everywhere; classes `final`, `readonly` when stateless.
+- Typed class constants (`public const string FOO = '…';`) — enforced by the Rector php83 set.
+- Domain depends on interfaces; concrete wiring lives in the Factory / DependencyProvider.
+- DTO suffixes: `Transfer` for data flowing into operations, `Result` for handler output.
+ No `T` prefix in this repo. See `src/Shared/Transfer/`.
+- Conventional commits, `ref:` instead of `refactor:`.
+
+## Testing
+
+- `tests/Unit/` mirrors `src/` — plain PHPUnit, no bootstrap.
+- `tests/Feature/` boots Gacela and overrides `InvoiceDependencyProvider` to inject
+ `FakeHttpApi`; fixtures in `tests/Feature/backends.json`.
+- PHPUnit 12: attributes, `createStub()`/`createMock()`, no `withConsecutive`.
+
+## Commands
+
+```bash
+composer test-all # quality (cs, psalm, phpstan) + phpunit + rector --dry-run
+composer quality # csrun + psalm + phpstan
+composer phpunit # tests only
+composer fix # php-cs-fixer + rector (writes changes)
+composer ctal # clear static caches, fix, then full suite
+composer serve # php -S localhost:8080 public/index.php
+```
+
+Psalm runs at errorLevel 1, PHPStan at level max. If PHPStan dies with an OOM inside
+`resultCache.php`, that is a stale cache — run `vendor/bin/phpstan clear-result-cache`,
+not a bigger memory limit.
diff --git a/README.md b/README.md
index 0e78877..38eb617 100644
--- a/README.md
+++ b/README.md
@@ -13,187 +13,86 @@
-
+
-Self-host your own [Lightning Address](https://lightningaddress.com) in PHP: a human-readable identifier like `you@yourdomain.com` that any Lightning wallet can pay. It implements [LNURL-pay (LUD-06)](https://github.com/lnurl/luds/blob/luds/06.md) and is backend-agnostic — [LNbits](https://lnbits.com) is the backend available today. Built on the [Gacela](https://gacela-project.com) framework.
+Self-host your own [Lightning Address](https://lightningaddress.com) in PHP: a
+human-readable identifier like `you@yourdomain.com` that any Lightning wallet can pay. It
+implements [LNURL-pay (LUD-06)](https://github.com/lnurl/luds/blob/luds/06.md) and is
+backend-agnostic — [LNbits](https://lnbits.com) is the backend available today. Built on
+the [Gacela](https://gacela-project.com) framework.
-## Requirements
+## Quick start
-- PHP >= 8.2
-
-## Install
+Requires PHP >= 8.3 and an LNbits wallet API key.
```bash
-composer require php-lightning/lnaddress
+composer require php-lightning/lnaddress # or clone this repo + composer install
+cp lightning-config.dist.php lightning-config.php # settings
+cp backends.dist.json backends.json # per-user invoice backends
+composer serve # http://localhost:8080
```
-`composer install` runs a post-install step that copies `backends.dist.json` → `backends.json` if the latter does not exist yet.
-
-Prefer starting from a working project? Use the ready-made [demo template](https://github.com/php-lightning/demo-lnaddress). It depends on this library, so a `composer update` pulls in new features and fixes as they land here.
-
-## Configure
-
-There are two config files: `lightning-config.php` (settings) and `backends.json` (per-user invoice backends).
-
-### 1. Settings — `lightning-config.php`
-
```bash
-cp lightning-config.dist.php lightning-config.php
+curl 'http://localhost:8080/bob' # → LNURL-pay params
+curl 'http://localhost:8080/bob?amount=2000' # → bolt11 invoice (2000 millisats)
```
-`LightningConfig` has a fluent API:
-
```php
+// lightning-config.php
use PhpLightning\Config\LightningConfig;
return (new LightningConfig())
->setDomain('yourdomain.com')
->setReceiver('default-receiver')
- ->setDescriptionTemplate('Pay to %s') // %s = the lightning address
+ ->setDescriptionTemplate('Pay to %s') // %s = the lightning address
->setSuccessMessage('Thanks for the payment!')
- ->setInvoiceMemo('')
->setSendableRange(min: 100_000, max: 10_000_000_000) // millisats
->setCallbackUrl('https://yourdomain.com')
->addBackendsFile(getcwd() . '/backends.json');
```
-### 2. Backends — `backends.json`
-
-```bash
-cp backends.dist.json backends.json
-```
-
-Each username maps to its own invoice backend:
-
```json
+// backends.json
{
"bob": { "type": "lnbits", "api_key": "abc...123", "api_endpoint": "http://localhost:5000" },
"alice": { "type": "lnbits", "api_key": "def...456", "api_endpoint": "http://localhost:5000" }
}
```
-### Register backends programmatically (no JSON file)
+Wallets resolve `bob@yourdomain.com` through
+`https://yourdomain.com/.well-known/lnurlp/bob`, so route that path to this app's
+`/{username}` over HTTPS.
-You can skip `backends.json` and register backends directly in `lightning-config.php`:
+## Documentation
-```php
-use PhpLightning\Config\Backend\LnBitsBackendConfig;
+| Guide | Contents |
+|---|---|
+| [Getting started](docs/getting-started.md) | Install, configure, run, deploy |
+| [Configuration](docs/configuration.md) | Every setter, defaults, backends file |
+| [HTTP API](docs/api.md) | Routes, payloads, CORS, error objects |
+| [Architecture](docs/architecture.md) | Modules, layers, adding a backend |
+| [Development](docs/development.md) | Scripts, tests, static analysis, releasing |
-$config->addBackend('bob', LnBitsBackendConfig::withEndpointAndKey('http://localhost:5000', 'abc...123'));
-```
+Also: [CHANGELOG](CHANGELOG.md) ·
+[wiki](https://github.com/php-lightning/lnaddress/wiki) ·
+[demo template](https://github.com/php-lightning/demo-lnaddress)
-## Run the server
+## Development
```bash
-composer serve
+composer test-all # php-cs-fixer + psalm + phpstan + phpunit + rector (dry-run)
+composer fix # apply php-cs-fixer and rector changes
```
-This starts `php -S localhost:8080 public/index.php`.
-
-## HTTP API
-
-One route serves the full LNURL-pay flow: `GET /{username?}`. The username is optional — when omitted, the request resolves to the default `receiver@domain` from your config.
-
-Every response carries permissive CORS headers (`Access-Control-Allow-Origin: *`) so browser-based wallets can call it, and `OPTIONS` preflight requests are answered directly. Uncaught errors are turned into the LNURL error object by a global handler.
-
-### Step 1 — pay params
-
-`GET /bob` (no `amount`) returns the LNURL-pay parameters:
-
-```json
-{
- "callback": "https://yourdomain.com",
- "maxSendable": 10000000000,
- "minSendable": 100000,
- "metadata": "[[\"text/plain\",\"Pay to bob@yourdomain.com\"],[\"text/identifier\",\"bob@yourdomain.com\"]]",
- "tag": "payRequest",
- "commentAllowed": false
-}
-```
-
-### Step 2 — invoice
-
-`GET /bob?amount=` returns a bolt11 invoice for that amount:
-
-```json
-{
- "pr": "lnbc20n1p...",
- "status": "OK",
- "memo": "",
- "successAction": { "tag": "message", "message": "Thanks for the payment!" },
- "routes": [],
- "disposable": false,
- "error": null
-}
-```
-
-### Errors
-
-Failures return an LNURL error object, for example when the amount falls outside the sendable range or the backend is unreachable:
-
-```json
-{ "status": "ERROR", "reason": "Amount is not between minimum and maximum sendable amount" }
-```
-
-> **Units:** the sendable range and the `amount` query param are in **millisats**. The backend is billed in **sats** (`millisats / 1000`).
-
-## Use as a library (programmatic)
-
-You can call the facade directly instead of going over HTTP:
-
-```php
-use Gacela\Framework\Gacela;
-use PhpLightning\Invoice\InvoiceFacade;
-
-Gacela::bootstrap(__DIR__);
-
-$facade = new InvoiceFacade();
-$payParams = $facade->getCallbackUrl('bob'); // LNURL-pay params
-$invoice = $facade->generateInvoice('bob', 2_000); // millisats
-```
-
-## Configuration reference
-
-| Setter | Purpose | Default |
-| --- | --- | --- |
-| `setDomain(string)` | Your domain (URL scheme is stripped) | — |
-| `setReceiver(string)` | Default username when none is in the URL | — |
-| `setSendableRange(int $min, int $max)` | Allowed amounts, in millisats | `100_000` – `10_000_000_000` |
-| `setCallbackUrl(string)` | Public callback base URL wallets call back to | — |
-| `setDescriptionTemplate(string)` | LNURL metadata description (`%s` = the address) | `Pay to %s` |
-| `setSuccessMessage(string)` | Message shown after a successful payment | `Payment received!` |
-| `setInvoiceMemo(string)` | Memo attached to the invoice | `''` |
-| `addBackendsFile(string $path)` / `addBackend(string $username, ...)` | Register invoice backends | — |
-
-## Adding a new backend
-
-Backends are keyed by a `type` string, resolved through the `PhpLightning\Config\Backend\BackendType` enum. To add one:
-
-- Add a case to `PhpLightning\Config\Backend\BackendType`.
-- Handle that case in `LightningConfig::createBackendConfig()`.
-- Implement `PhpLightning\Invoice\Domain\BackendInvoice\BackendInvoiceInterface`.
-
-## Development / Testing
-
-```bash
-composer test-all # quality + phpunit + rector (dry-run)
-```
-
-Other useful scripts:
-
-- `composer test-phpunit` — run the PHPUnit suite
-- `composer quality` — php-cs-fixer (dry-run), psalm, phpstan
-- `composer fix` — php-cs-fixer + rector (apply fixes)
-
-See [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) before opening a PR.
-
-## Wiki
+## Contributing
-More details in the [wiki](https://github.com/php-lightning/lnaddress/wiki).
+Issues and pull requests are welcome — read
+[CONTRIBUTING](.github/CONTRIBUTING.md) and the
+[Code of Conduct](.github/CODE_OF_CONDUCT.md), and run `composer test-all` first.
-## Contributions
+## License
-Issues and pull requests are welcome. Licensed under [MIT](LICENSE).
+MIT — see [LICENSE](LICENSE).
diff --git a/composer.json b/composer.json
index 19c2e32..6fffc06 100644
--- a/composer.json
+++ b/composer.json
@@ -3,23 +3,23 @@
"type": "library",
"license": "MIT",
"require": {
- "php": ">=8.2",
- "gacela-project/gacela": "^1.19",
+ "php": ">=8.3",
+ "gacela-project/gacela": "^1.21",
"gacela-project/router": "^0.13",
- "symfony/http-client": "^7.2"
+ "symfony/http-client": "^7.4"
},
"require-dev": {
- "friendsofphp/php-cs-fixer": "^3.75",
+ "friendsofphp/php-cs-fixer": "^3.95",
"gacela-project/phpstan-extension": "^0.4",
"phpstan/phpstan": "^2.2",
- "phpunit/phpunit": "^10.5",
+ "phpunit/phpunit": "^12.5",
"psalm/plugin-phpunit": "^0.19",
- "rector/rector": "^2.0",
- "vimeo/psalm": "^6.11"
+ "rector/rector": "^2.5",
+ "vimeo/psalm": "^6.16"
},
"config": {
"platform": {
- "php": "8.2.27"
+ "php": "8.3.16"
},
"allow-plugins": {
"composer/package-versions-deprecated": true
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..0027ed6
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,33 @@
+# Documentation
+
+`php-lightning/lnaddress` turns a domain you control into a
+[Lightning Address](https://lightningaddress.com/) provider: it implements
+[LNURL-pay (LUD-06)](https://github.com/lnurl/luds/blob/luds/06.md) and issues invoices
+through a pluggable backend ([LNbits](https://lnbits.com/) today).
+
+| Guide | What's inside |
+|---|---|
+| [Getting started](getting-started.md) | Install, configure, run locally, deploy |
+| [Configuration](configuration.md) | Every `LightningConfig` setter, `backends.json`, defaults |
+| [HTTP API](api.md) | Routes, payloads, CORS, error objects |
+| [Architecture](architecture.md) | Gacela modules, layers, request flow, adding a backend |
+| [Development](development.md) | Scripts, tests, static analysis, releasing |
+
+## In one minute
+
+```bash
+composer install # copies backends.dist.json → backends.json
+cp lightning-config.dist.php lightning-config.php # domain, receiver, callback URL
+composer serve # http://localhost:8080
+```
+
+```bash
+curl 'http://localhost:8080/bob' # LNURL-pay params
+curl 'http://localhost:8080/bob?amount=2000' # bolt11 invoice for 2000 msat
+```
+
+## Requirements
+
+- PHP >= 8.3
+- An LNbits instance (or compatible endpoint) with an invoice/read API key
+- HTTPS on a public domain for real-world use
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..319234d
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,114 @@
+# HTTP API
+
+A single route serves the whole [LNURL-pay flow](https://github.com/lnurl/luds/blob/luds/06.md):
+`GET|OPTIONS /{username?}`. The `amount` query parameter decides which step you get.
+
+| Request | Step | Response |
+|---|---|---|
+| `GET /{username}` | LNURL-pay request | pay params (`tag: payRequest`) |
+| `GET /{username}?amount={msat}` | LNURL-pay callback | bolt11 invoice under `pr` |
+| `OPTIONS /{username}` | CORS preflight | empty body, CORS headers |
+
+`username` is optional — without it the configured `receiver` is used. `amount` is in
+**millisatoshis**; the backend is billed in sats (`amount / 1000`).
+
+Every response carries `Access-Control-Allow-Origin: *` (`CorsMiddleware`), so
+browser-based wallets can call the endpoint directly.
+
+## 1. Pay params
+
+```bash
+curl 'https://example.com/bob'
+```
+
+```json
+{
+ "callback": "https://example.com",
+ "maxSendable": 10000000000,
+ "minSendable": 100000,
+ "metadata": "[[\"text/plain\",\"Pay to bob@example.com\"],[\"text/identifier\",\"bob@example.com\"]]",
+ "tag": "payRequest",
+ "commentAllowed": false
+}
+```
+
+- `callback` is `setCallbackUrl()` verbatim — wallets append `?amount=`.
+- `metadata` is built by `LnurlPayMetadata` with `json_encode`, so quotes in your
+ description or address cannot break the JSON.
+- `commentAllowed` is always `false` (LUD-12 comments are not implemented).
+
+## 2. Invoice
+
+```bash
+curl 'https://example.com/bob?amount=2000'
+```
+
+```json
+{
+ "pr": "lnbc20n1p...",
+ "status": "OK",
+ "memo": "",
+ "successAction": { "tag": "message", "message": "Payment received!" },
+ "routes": [],
+ "disposable": false,
+ "error": null
+}
+```
+
+The bolt11 invoice is under `pr`, as LUD-06 requires. The backend receives the amount in
+sats plus `description_hash` (sha256 of the metadata) and `unhashed_description`.
+
+## Errors
+
+Errors come back as the LNURL error object. Uncaught exceptions are converted by
+`InvoiceExceptionHandler`, registered globally in `InvoiceRoutesPlugin`, so no controller
+needs a try/catch.
+
+```json
+{ "status": "ERROR", "reason": "Amount is not between minimum and maximum sendable amount" }
+```
+
+```json
+{ "status": "ERROR", "reason": "Missing backend options for carol" }
+```
+
+An unreachable backend answers with `status: ERROR` and
+`error: Backend "LnBits" unreachable`.
+
+## Serving a real Lightning Address
+
+Wallets resolve `bob@example.com` through:
+
+```
+https://example.com/.well-known/lnurlp/bob
+```
+
+Route that path to `/{username}`. With nginx:
+
+```nginx
+location /.well-known/lnurlp/ {
+ rewrite ^/\.well-known/lnurlp/(.*)$ /$1 last;
+}
+
+location / {
+ try_files $uri /index.php$is_args$args;
+}
+```
+
+Requirements for interoperability: HTTPS, JSON responses, and a `callback` URL reachable
+from the public internet.
+
+## Calling it without HTTP
+
+The facade is usable directly, which is what the feature tests do:
+
+```php
+use Gacela\Framework\Gacela;
+use PhpLightning\Invoice\InvoiceFacade;
+
+Gacela::bootstrap(__DIR__);
+
+$facade = new InvoiceFacade();
+$payParams = $facade->getCallbackUrl('bob');
+$invoice = $facade->generateInvoice('bob', 2_000); // millisats
+```
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..1d71d4b
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,94 @@
+# Architecture
+
+A small [Gacela](https://gacela-project.com) modular monolith: every module exposes a
+**Facade**, builds its objects in a **Factory**, reads settings through a **Config**, and
+declares external dependencies in a **Provider**. Nothing outside a module touches its
+internals.
+
+
+
+## Request flow
+
+```
+public/index.php
+ └─ Gacela::bootstrap() gacela.php: app config, router, plugins
+ └─ Router::run()
+ └─ InvoiceRoutesPlugin GET|OPTIONS {username?}
+ ├─ CorsMiddleware CORS headers; short-circuits OPTIONS
+ ├─ InvoiceExceptionHandler any Throwable → LNURL error object
+ └─ InvoiceController reads ?amount
+ └─ InvoiceFacade
+ ├─ getCallbackUrl($username) amount == 0
+ │ └─ Application\CallbackUrl → pay params
+ └─ generateInvoice($username, $msat)
+ └─ Application\InvoiceGenerator
+ └─ Domain\BackendInvoice\LnbitsBackendInvoice
+ └─ Infrastructure\Http\HttpApi (Symfony HttpClient)
+```
+
+## Module layout
+
+```
+src/
+├── Invoice/ the feature module
+│ ├── InvoiceFacade.php public API @extends AbstractFacade
+│ ├── InvoiceFactory.php wiring @extends AbstractFactory
+│ ├── InvoiceConfig.php typed config reads + per-user backend lookup
+│ ├── InvoiceDependencyProvider.php external deps (HTTP_API)
+│ ├── Application/ CallbackUrl, InvoiceGenerator
+│ ├── Domain/ BackendInvoice, CallbackUrl, Http — interfaces + logic
+│ └── Infrastructure/ Controller, Handler, Middleware, Http, Plugin
+├── Config/ LightningConfig, BackendsConfig, BackendType, LnBitsBackendConfig
+└── Shared/ Config\ConfigKey, Transfer\*, Value\SendableRange, Value\LnurlPayMetadata
+```
+
+**Layer rules**
+
+- `Application` orchestrates and depends on `Domain` interfaces only.
+- `Domain` is pure: no HTTP, no framework, no config lookups — collaborators arrive as
+ constructor arguments (`HttpApiInterface`, `BackendInvoiceInterface`,
+ `LnAddressGeneratorInterface`).
+- `Infrastructure` holds everything that talks to the outside world.
+- `Shared` carries data across modules: `Transfer` suffix for DTOs, value objects for
+ invariants (`SendableRange`, `LnurlPayMetadata`).
+
+**Gacela specifics**
+
+- The abstract classes are templated: annotate with `@extends AbstractFacade`
+ and `@extends AbstractFactory`.
+- `InvoiceController` resolves its facade through `ServiceResolverAwareTrait` plus the
+ `@method InvoiceFacade getFacade()` docblock (`DocBlockResolverAwareTrait` is deprecated).
+- `InvoiceDependencyProvider` registers `HTTP_API`; that seam is how the Symfony-backed
+ `HttpApi` reaches the domain, and how feature tests swap in `FakeHttpApi`.
+
+## Key objects
+
+| Class | Responsibility |
+|---|---|
+| `Application\CallbackUrl` | Builds the LUD-06 pay params (`callback`, min/max sendable, metadata) |
+| `Application\InvoiceGenerator` | Validates the amount, converts msat → sat, maps `InvoiceTransfer` to the response |
+| `Domain\CallbackUrl\LnAddressGenerator` | Resolves `username@domain`, falling back to the configured receiver |
+| `Domain\BackendInvoice\LnbitsBackendInvoice` | `POST {api_endpoint}/api/v1/payments` with `X-Api-Key`, `description_hash`, `unhashed_description` |
+| `Domain\BackendInvoice\EmptyBackendInvoice` | Null object for users without a usable backend |
+| `Infrastructure\Middleware\CorsMiddleware` | `Access-Control-Allow-Origin: *`; answers `OPTIONS` preflight |
+| `Infrastructure\Handler\InvoiceExceptionHandler` | Turns any `Throwable` into `{status: ERROR, reason: …}` |
+| `Shared\Value\SendableRange` | Min/max msat bounds and `contains()` |
+| `Shared\Value\LnurlPayMetadata` | LUD-06 metadata, JSON-encoded so quotes stay safe |
+| `Shared\Config\ConfigKey` | The config key strings, shared by writer and reader |
+
+## Adding a backend
+
+`LnbitsBackendInvoice` is one implementation of `BackendInvoiceInterface`:
+
+1. Add a case to `PhpLightning\Config\Backend\BackendType`.
+2. Handle it in `LightningConfig::createBackendConfig()` with a
+ `BackendConfigInterface` implementation.
+3. Implement `BackendInvoiceInterface` under `Invoice/Domain/BackendInvoice/`.
+4. Select it in `InvoiceFactory::getBackendForUser()`.
+
+The Application layer stays untouched — it only knows the interface.
+
+## Not implemented
+
+Marked as TODO in the code: LNURL comments (`commentAllowed` is always `false`) and image
+metadata in the pay params.
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..a576418
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,101 @@
+# Configuration
+
+Two files drive the app, both read at bootstrap through `gacela.php`:
+
+| File | Purpose | In git |
+|---|---|---|
+| `lightning-config.dist.php` | Shipped defaults | yes |
+| `lightning-config.php` | Your overrides — wins over the dist file | no |
+| `backends.dist.json` | Example backends | yes |
+| `backends.json` | Your users + API keys | no |
+
+## `LightningConfig`
+
+`lightning-config.php` returns a `PhpLightning\Config\LightningConfig`. Only the values you
+set are serialized, so anything you leave out keeps its default.
+
+```php
+use PhpLightning\Config\LightningConfig;
+
+return (new LightningConfig())
+ ->setDomain('example.com')
+ ->setReceiver('bob')
+ ->setDescriptionTemplate('Pay to %s')
+ ->setSuccessMessage('Payment received!')
+ ->setInvoiceMemo('coffee fund')
+ ->setSendableRange(min: 100_000, max: 10_000_000_000)
+ ->setCallbackUrl('https://example.com')
+ ->addBackendsFile(getcwd() . DIRECTORY_SEPARATOR . 'backends.json');
+```
+
+| Setter | Config key | Default | Notes |
+|---|---|---|---|
+| `setDomain(string)` | `domain` | `$_SERVER['HTTP_HOST']`, else `localhost` | A full URL is accepted; only the host is kept |
+| `setReceiver(string)` | `receiver` | `unknown-receiver` | Username used when the URL has none |
+| `setCallbackUrl(string)` | `callback-url` | `undefined:callback-url` | Returned as `callback`; wallets must be able to reach it |
+| `setSendableRange(int $min, int $max)` | `sendable-range` | `100_000` – `10_000_000_000` msat | Amounts outside the range are rejected |
+| `setDescriptionTemplate(string)` | `description-template` | `Pay to %s` | `%s` is the Lightning Address; shown in the payer's wallet |
+| `setSuccessMessage(string)` | `success-message` | `Payment received!` | Returned as `successAction.message` |
+| `setInvoiceMemo(string)` | `invoice-memo` | `''` | Memo stored on the invoice at the backend |
+| `addBackendsFile(string $path)` | `backends` | — | Throws if the file does not exist; callable more than once |
+| `addBackend(string $username, BackendConfigInterface)` | `backends` | — | Register a backend without a JSON file |
+
+Amounts are always **millisatoshis** (msat); the default range is 100 sat to
+10 000 000 sat. The key strings live in `PhpLightning\Shared\Config\ConfigKey`, shared by
+the writer (`LightningConfig`) and the reader (`InvoiceConfig`) so they cannot drift.
+
+## Backends file (`backends.json`)
+
+A map of username to backend settings. The username is the local part of the Lightning
+Address (`bob@example.com` -> `bob`) and the path segment of the request (`/bob`).
+
+```json
+{
+ "bob": {
+ "type": "lnbits",
+ "api_key": "abc...123",
+ "api_endpoint": "https://legend.lnbits.com"
+ },
+ "alice": {
+ "type": "lnbits",
+ "api_key": "def...456",
+ "api_endpoint": "http://localhost:5000"
+ }
+}
+```
+
+- `type` is resolved through the `BackendType` enum; an unknown value fails fast with
+ `Unknown backend type "x". Supported types: lnbits`.
+- `api_endpoint` loses any trailing slash; the client calls
+ `{api_endpoint}/api/v1/payments`.
+- `api_key` is an LNbits **invoice/read** key — it only needs invoice-creation rights.
+- Requesting a username that is not listed fails with
+ `Missing backend options for `.
+
+Treat `backends.json` as a secret; it is gitignored on purpose.
+
+### Without a JSON file
+
+```php
+use PhpLightning\Config\Backend\LnBitsBackendConfig;
+
+$config->addBackend('bob', LnBitsBackendConfig::withEndpointAndKey('http://localhost:5000', 'abc...123'));
+```
+
+## Gacela bootstrap (`gacela.php`)
+
+```php
+return static function (GacelaConfig $config): void {
+ $config
+ ->enableFileCache()
+ ->addAppConfig('lightning-config.dist.php', 'lightning-config.php')
+ ->extendGacelaConfig(RouterGacelaConfig::class)
+ ->addPlugin(InvoiceRoutesPlugin::class);
+};
+```
+
+- `addAppConfig(dist, override)` is what makes `lightning-config.php` optional.
+- `enableFileCache()` caches resolved classes/config under `.gacela/`; delete that
+ directory if a stale value survives a config change.
+- `InvoiceRoutesPlugin` registers the route, the CORS middleware and the exception
+ handler.
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..527661e
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,96 @@
+# Development
+
+```bash
+composer install
+composer test-all
+```
+
+## Composer scripts
+
+| Script | What it runs |
+|---|---|
+| `composer test-all` | `quality` + PHPUnit + `rector --dry-run` — the CI gate |
+| `composer quality` | php-cs-fixer (dry-run), Psalm, PHPStan |
+| `composer phpunit` | PHPUnit only |
+| `composer test-coverage` | PHPUnit with HTML coverage in `data/coverage-html` |
+| `composer fix` | php-cs-fixer + Rector, writing changes |
+| `composer ctal` | clear static caches, `fix`, then `test-all` |
+| `composer static-clear-cache` | Clear the Psalm and PHPStan caches |
+| `composer serve` | `php -S localhost:8080 public/index.php` |
+
+Static analysis runs with `XDEBUG_MODE=off`; coverage flips it to `coverage`.
+
+## Tests
+
+- `tests/Unit/` mirrors `src/` and needs no bootstrap — plain PHPUnit with stubs/mocks.
+- `tests/Feature/` boots Gacela end to end. `tests/Feature/backends.json` supplies the
+ fixture users and `tests/Feature/Fake/FakeHttpApi` replaces the HTTP client, so no
+ network call ever happens.
+
+A feature test bootstraps Gacela with in-line config, then overrides the module's provider
+to inject the fake:
+
+```php
+Gacela::bootstrap(__DIR__, static function (GacelaConfig $config): void {
+ $config->resetInMemoryCache();
+ $config->addAppConfigKeyValues(
+ (new LightningConfig())
+ ->setCallbackUrl('https://callback.url/receiver')
+ ->setDomain('domain.com')
+ ->setReceiver('receiver')
+ ->setSendableRange(1_000, 10_000)
+ ->addBackendsFile(__DIR__ . DIRECTORY_SEPARATOR . 'backends.json')
+ ->jsonSerialize(),
+ );
+});
+
+Gacela::overrideExistingResolvedClass(
+ InvoiceDependencyProvider::class,
+ new class() extends AbstractProvider {
+ public function provideModuleDependencies(Container $container): void
+ {
+ $container->set(InvoiceDependencyProvider::HTTP_API, static fn () => new FakeHttpApi());
+ }
+ },
+);
+```
+
+PHPUnit 12 is in use: attributes instead of annotations, `createStub()` / `createMock()`,
+no `withConsecutive()`.
+
+## Static analysis
+
+- **Psalm** at `errorLevel="1"`, target PHP 8.3, PHPUnit plugin enabled.
+- **PHPStan** at `level: max` with the gacela extension (`modulesNamespace: PhpLightning`).
+- **Rector** with the PHP 8.3 set plus dead-code and code-quality sets, over `src/` and
+ `tests/`.
+- **php-cs-fixer** with `.php-cs-fixer.dist.php`.
+
+Fix findings at the source instead of widening the ignore lists. If PHPStan dies with an
+out-of-memory error inside `resultCache.php`, the cause is a stale result cache — run
+`vendor/bin/phpstan clear-result-cache`, not a bigger memory limit.
+
+## Conventions
+
+- `declare(strict_types=1);` everywhere; classes `final`, `readonly` when stateless.
+- Typed class constants (`public const string FOO = '…';`).
+- Domain code depends on interfaces; wiring lives in the Factory / DependencyProvider.
+- DTOs live in `src/Shared/Transfer/` with the `Transfer` suffix.
+- [Conventional commits](https://www.conventionalcommits.org/); `ref:` for refactors.
+
+## CI
+
+`.github/workflows/ci.yml` runs on every push and pull request:
+
+1. **Coding Guidelines** — php-cs-fixer dry-run
+2. **Type Checker** — Psalm (with Shepherd) and PHPStan
+3. **Tests** — PHPUnit on PHP 8.3, 8.4 and 8.5, with locked and highest dependencies
+
+Scrutinizer (`.scrutinizer.yml`) collects coverage separately.
+
+## Releasing
+
+1. Move the `Unreleased` entries in [`CHANGELOG.md`](../CHANGELOG.md) under the new
+ version and date, and refresh the compare links.
+2. Tag `main` (`0.10.0` — tags are unprefixed since 0.2.0).
+3. Push the tag and publish the GitHub release with that changelog section as the notes.
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..2e7105f
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,99 @@
+# Getting started
+
+## Requirements
+
+- PHP >= 8.3
+- Composer 2
+- An LNbits wallet (self-hosted or hosted) — you need its API endpoint and API key
+
+## Install
+
+### Standalone
+
+```bash
+git clone https://github.com/php-lightning/lnaddress.git
+cd lnaddress
+composer install
+```
+
+The `post-install-cmd` script copies `backends.dist.json` to `backends.json` when the
+latter does not exist yet.
+
+### As a dependency
+
+```bash
+composer require php-lightning/lnaddress
+```
+
+The [demo template](https://github.com/php-lightning/demo-lnaddress) is a ready-made
+project built on top of this library: it keeps your config and pulls features and fixes
+with `composer update`.
+
+## Configure
+
+Two files, both gitignored so your keys never reach version control:
+
+```bash
+cp lightning-config.dist.php lightning-config.php
+cp backends.dist.json backends.json # already done by composer install
+```
+
+**`backends.json`** — one entry per username you serve:
+
+```json
+{
+ "bob": {
+ "type": "lnbits",
+ "api_key": "abc...123",
+ "api_endpoint": "https://legend.lnbits.com"
+ }
+}
+```
+
+**`lightning-config.php`** — everything else:
+
+```php
+setDomain('example.com')
+ ->setReceiver('bob')
+ ->setDescriptionTemplate('Pay to %s')
+ ->setSuccessMessage('Thanks for the payment!')
+ ->setInvoiceMemo('')
+ ->setSendableRange(min: 100_000, max: 10_000_000_000)
+ ->setCallbackUrl('https://example.com')
+ ->addBackendsFile(getcwd() . DIRECTORY_SEPARATOR . 'backends.json');
+```
+
+`lightning-config.php` overrides `lightning-config.dist.php` key by key. Full reference in
+[Configuration](configuration.md).
+
+## Run locally
+
+```bash
+composer serve # php -S localhost:8080 public/index.php
+```
+
+```bash
+curl 'http://localhost:8080/bob'
+curl 'http://localhost:8080/bob?amount=2000'
+```
+
+> The `callback` URL is what paying wallets call in step 2 of the LNURL-pay flow, so it
+> must match the host you actually serve — `http://localhost:8080` while testing locally.
+
+## Go live
+
+Wallets resolve `bob@example.com` by fetching:
+
+```
+https://example.com/.well-known/lnurlp/bob
+```
+
+Serve `public/index.php` on that domain over HTTPS and rewrite
+`/.well-known/lnurlp/{username}` to `/{username}`. Keep `setDomain()` and
+`setCallbackUrl()` in sync with the public URL. Details and an nginx snippet in
+[HTTP API](api.md).
diff --git a/phpunit.xml b/phpunit.xml
index fc6c350..e1912e0 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,5 +1,16 @@
-
+
diff --git a/psalm.xml b/psalm.xml
index 07d925b..51040c1 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -1,7 +1,7 @@
withPhpSets(php82: true)
+ ->withPhpSets(php83: true)
->withPreparedSets(
deadCode: true,
codeQuality: true,
diff --git a/src/Invoice/Application/CallbackUrl.php b/src/Invoice/Application/CallbackUrl.php
index 5fcde3c..3c8dc69 100644
--- a/src/Invoice/Application/CallbackUrl.php
+++ b/src/Invoice/Application/CallbackUrl.php
@@ -11,7 +11,7 @@
final readonly class CallbackUrl implements CallbackUrlInterface
{
- private const TAG_PAY_REQUEST = 'payRequest';
+ private const string TAG_PAY_REQUEST = 'payRequest';
public function __construct(
private SendableRange $sendableRange,
diff --git a/src/Invoice/InvoiceDependencyProvider.php b/src/Invoice/InvoiceDependencyProvider.php
index 31f7fed..28c502b 100644
--- a/src/Invoice/InvoiceDependencyProvider.php
+++ b/src/Invoice/InvoiceDependencyProvider.php
@@ -13,7 +13,7 @@
*/
final class InvoiceDependencyProvider extends AbstractProvider
{
- public const HTTP_API = 'HTTP_API';
+ public const string HTTP_API = 'HTTP_API';
public function provideModuleDependencies(Container $container): void
{
diff --git a/src/Shared/Config/ConfigKey.php b/src/Shared/Config/ConfigKey.php
index 2e8f4e0..70902de 100644
--- a/src/Shared/Config/ConfigKey.php
+++ b/src/Shared/Config/ConfigKey.php
@@ -11,12 +11,12 @@
*/
final class ConfigKey
{
- public const BACKENDS = 'backends';
- public const DOMAIN = 'domain';
- public const RECEIVER = 'receiver';
- public const SENDABLE_RANGE = 'sendable-range';
- public const CALLBACK_URL = 'callback-url';
- public const DESCRIPTION_TEMPLATE = 'description-template';
- public const SUCCESS_MESSAGE = 'success-message';
- public const INVOICE_MEMO = 'invoice-memo';
+ public const string BACKENDS = 'backends';
+ public const string DOMAIN = 'domain';
+ public const string RECEIVER = 'receiver';
+ public const string SENDABLE_RANGE = 'sendable-range';
+ public const string CALLBACK_URL = 'callback-url';
+ public const string DESCRIPTION_TEMPLATE = 'description-template';
+ public const string SUCCESS_MESSAGE = 'success-message';
+ public const string INVOICE_MEMO = 'invoice-memo';
}
diff --git a/src/Shared/Value/SendableRange.php b/src/Shared/Value/SendableRange.php
index 1c3fc09..e235420 100644
--- a/src/Shared/Value/SendableRange.php
+++ b/src/Shared/Value/SendableRange.php
@@ -7,10 +7,10 @@
final readonly class SendableRange
{
/** @var int 100 Minimum in msat (sat/1000) */
- private const DEFAULT_MIN_SENDABLE_IN_MILLISATS = 100_000;
+ private const int DEFAULT_MIN_SENDABLE_IN_MILLISATS = 100_000;
/** @var int 10 000 000 Maximum in msat (sat/1000) */
- private const DEFAULT_MAX_SENDABLE_IN_MILLISATS = 10_000_000_000;
+ private const int DEFAULT_MAX_SENDABLE_IN_MILLISATS = 10_000_000_000;
private function __construct(
private int $min,