Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ jobs:
- name: Install PHP dependencies
run: composer install --no-interaction --prefer-dist

- name: Build the bundle from bundle.lock
# Against the host's 3.x branch: a bundle that no longer fits the next
# host release fails here before it fails a release.
run: |
curl --fail --silent --show-error --location \
https://raw.githubusercontent.com/InvoiceShelf/InvoiceShelf/3.x/composer.lock \
--output "$RUNNER_TEMP/host-composer.lock"
vendor/bin/invoiceshelf-module bundle . --host-lock="$RUNNER_TEMP/host-composer.lock"

- name: Verify PHP and package contracts
run: |
composer run lint
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ permissions:

jobs:
release:
uses: InvoiceShelf/modules/.github/workflows/module-release.yml@3.4.0
uses: InvoiceShelf/modules/.github/workflows/module-release.yml@3.5.0
with:
channel: stable
# The oldest InvoiceShelf release this version supports; the bundle is built against its composer.lock.
host-version: 3.0.0-alpha.12
secrets: inherit
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
/release-manifest.canonical.json
/release-manifest.sig

/bundle/
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

Guide for coding agents working on the official Hello World module for InvoiceShelf 3.

- **What it is.** The smallest complete module: a page registered with `registerPage`, a sidebar entry, a per-company settings form, committed `dist/` assets, a data cleanup hook and a signed release workflow. It is the module authors' reference, so every change should still read as an example.
- **What it is.** The smallest complete module: a page registered with `registerPage`, a sidebar entry, a per-company settings form, record extension slots, a bundled third-party package, committed `dist/` assets, a data cleanup hook and a signed release workflow. It is the module authors' reference, so every change should still read as an example.
- **SDK.** `invoiceshelf/modules` comes from the `InvoiceShelf/modules` VCS repository. Read its README before changing anything that touches the module contract (`module.json`, `composer.json`, the release workflow).
- **Frontend.** `resources/js/init.ts` registers everything inside `window.InvoiceShelf.booting(...)`. Vue is the host's (`window.__invoiceshelf_vue`, see `vite.config.js`), and so are the globally registered `Base*` components. Never call `createApp`. Rebuild and commit `dist/init.js` after any change under `resources/js/`: the host does not run Node.
- **Bundled dependency.** `dependency_policy` is `bundled`: `chillerlan/php-qrcode` (required in `composer.json`) ships in the generated, gitignored `bundle/`, pinned by the committed `bundle.lock`. Rebuild with `vendor/bin/invoiceshelf-module bundle . --host-lock=<host composer.lock>` (add `--update` after changing packages). Never edit `bundle/`, and keep tools out of it (`pint.json` excludes it).
- **Record slots.** `init.ts` registers a panel and a "Say hello" action on invoices and customers and a New-menu entry through the entity extension API; components get a `context` prop with a frozen record snapshot.
- **Checks.** `composer run lint`, `composer run test`, `vendor/bin/invoiceshelf-module validate-package .`, `pnpm run lint`, `pnpm exec tsc --noEmit`, `pnpm run build`; CI fails when `dist/` differs from a fresh build.
- **In a host.** See the README's "Local development". The page lives at `/admin/modules/hello-world/dashboard`.
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The same as CI:

```bash
composer install
vendor/bin/invoiceshelf-module bundle . --host-lock=<host composer.lock> # builds bundle/ from bundle.lock
composer run lint
composer run test
vendor/bin/invoiceshelf-module validate-module module.json
Expand All @@ -19,8 +20,8 @@ pnpm exec tsc --noEmit
pnpm run build # commit dist/: the host never runs Node
```

PHP 8.4, Node 24, pnpm 11. Running the module inside a host is described in the README under "Local development".
PHP 8.4, Node 24, pnpm 11. Running the module inside a host is described in the README under "Local development", and adding or updating a bundled package under "Dependency management": commit `bundle.lock`, never `bundle/`.

## Releasing

Set `version` in `module.json` and `package.json`, rebuild `dist/`, merge, then push an unprefixed tag equal to the version (`1.2.0`, not `v1.2.0`). The tag workflow calls the SDK's reusable release workflow, which validates, packages, signs and registers the release with the marketplace.
Set `version` in `module.json` and `package.json`, rebuild `dist/`, check `host-version` in `.github/workflows/release.yml` is the oldest InvoiceShelf release the version supports, merge, then push an unprefixed tag equal to the version (`1.2.0`, not `v1.2.0`). The tag workflow calls the SDK's reusable release workflow, which validates, packages, signs and registers the release with the marketplace.
70 changes: 59 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,23 @@

The official reference module for InvoiceShelf 3: the smallest complete module, meant to be read and copied.

It shows a page registered with `registerPage`, a sidebar entry, a per-company settings form, committed frontend assets, a data cleanup hook for uninstall, and the signed marketplace release workflow. Source and compiled assets are licensed `AGPL-3.0-only`.
It shows:

- a page registered with `registerPage`, a sidebar entry and a per-company settings form;
- extension slots on host records: a panel and a "Say hello" action on invoices and customers, and an entry in the global New menu;
- a **bundled third-party package**: the page's QR code is drawn on the server by [`chillerlan/php-qrcode`](https://github.com/chillerlan/php-qrcode), which InvoiceShelf does not ship;
- committed frontend assets, a data cleanup hook for uninstall, and the signed marketplace release workflow.

Source and compiled assets are licensed `AGPL-3.0-only`.

## Compatibility

| Dependency | Constraint in `module.json` |
| --- | --- |
| InvoiceShelf | `>=3.0.0-alpha.3 <4.0.0` |
| Module API | `^1.3.0` |
| InvoiceShelf | `>=3.0.0-alpha.12 <4.0.0` |
| Module API | `^1.4.0` (bundled dependencies and record slots) |
| PHP | `^8.4.0` |
| Extensions | `ext-json` |
| Extensions | `ext-json`, `ext-mbstring` (the bundled QR library needs it) |

The InvoiceShelf range names the alpha it starts at on purpose: a caret range such as `^3.0.0` excludes every `3.0.0-alpha.*` prerelease.

Expand All @@ -20,19 +27,60 @@ The InvoiceShelf range names the alpha it starts at on purpose: a caret range su
You need PHP 8.4, Composer, Node 24 and pnpm 11, and an InvoiceShelf 3.x checkout to run the module in.

1. **Put the module where the host looks for modules.** Clone it into the host's `Modules/HelloWorld` (the folder name is the module's `name` in `module.json`). In the InvoiceShelf devenv, keep `module-helloworld` as a sibling checkout and symlink it instead: `ln -s ../../module-helloworld InvoiceShelf/Modules/HelloWorld`, plus a bind mount of the sibling directory into the PHP container so the link resolves there too.
2. **Install the module's own tools.** In the module directory: `composer install` (the SDK, PHPUnit, Pint) and `pnpm install`.
3. **Build the frontend.** `pnpm run build` writes `dist/init.js`. There is no hot reload: rebuild after every change under `resources/js/` and reload the page.
4. **Install it into the host.** From the host: `php artisan install:module HelloWorld 1.2.0`. That runs the module's migrations, enables it and fires the lifecycle events, as a marketplace install would.
5. **Open it.** Sign in and use the Hello World entry in the sidebar (`/admin/modules/hello-world/dashboard`).
2. **Install the module's own tools.** In the module directory: `composer install` (the SDK, PHPUnit, Pint and the module's packages, for its tests) and `pnpm install`.
3. **Build the bundle** against the host you run:

```bash
vendor/bin/invoiceshelf-module bundle . --host-lock=../../composer.lock # module inside the host's Modules/
vendor/bin/invoiceshelf-module bundle . --host-lock=../InvoiceShelf/composer.lock # sibling checkout
```

This writes `bundle/` from the committed `bundle.lock`. Rebuild it whenever you change a package or update the host.
4. **Build the frontend.** `pnpm run build` writes `dist/init.js`. There is no hot reload: rebuild after every change under `resources/js/` and reload the page.
5. **Install it into the host.** From the host: `php artisan install:module HelloWorld 2.0.0`. That checks the bundle against the host, runs the module's migrations, enables it and fires the lifecycle events, as a marketplace install would. A missing or stale `bundle/` is refused with the command to run.
6. **Open it.** Sign in and use the Hello World entry in the sidebar (`/admin/modules/hello-world/dashboard`), then open any invoice or customer to see the panel and the "Say hello" action.

The module's tests run without a host, on Orchestra Testbench and the packages Composer installed in `vendor/`: `composer run test`.

A local host has its own development packages installed, and the host's copy of a package always wins over a bundled one. When a bundled package is also a development dependency of the host, `bundle` warns about it; the release is built against the host's production packages, where that cannot happen.

## Dependency management

A module never runs Composer on the server, and the host's `vendor/` is read-only. Where a module's PHP code comes from is its `dependency_policy` in `module.json`:

| Policy | `composer.json` may require | Use it when |
| --- | --- | --- |
| `host-provided-only` | `php`, `ext-*`, `invoiceshelf/modules` | Everything the module needs is in Laravel, the SDK or the host already. |
| `bundled` | the above plus third-party packages | The module needs a library the host does not ship. Needs module API `^1.4.0`. |

Hello World is `bundled`, for one package.

**Adding a package.** Require it as usual, then resolve the bundle again:

```bash
composer require vendor/package
vendor/bin/invoiceshelf-module bundle . --host-lock=<host composer.lock> --update
```

`bundle` resolves your third-party packages as if every production package of that host were already installed. Composer reuses what the host ships (and records the constraint in `bundle/bundle.json` under `host_packages`), installs only what the host lacks, and refuses outright when a package needs a version of a host package the host does not have. The result goes to:

- `bundle.lock`, **committed**: the exact versions the module ships. Review its diff like code: signing a release vouches for everything inside it.
- `bundle/`, **gitignored and generated**: the package files, `classmap.php` (which the host reads as data and loads after its own autoloader) and `bundle.json`. Keep your tools out of it (here, `"exclude": ["bundle"]` in `pint.json`).

Without `--update`, `bundle` installs exactly `bundle.lock` and fails when it no longer fits the host, when `composer.json` no longer needs a locked package, or when `composer.lock` holds different versions than the bundle (the tests run on `vendor/`, the archive ships `bundle/`).

**What a bundle must pass.** Every package's license must be one an AGPL-3.0-only module can convey (Composer's license list counts as alternatives), every extension a package needs must be listed in `compatibility.extensions` (core ones such as `ext-json` excepted), and no package may be a Composer plugin, define classes or functions in a host, Laravel, Composer or `Modules\` namespace, contain a symbolic link, or push the archive past the marketplace's 10,000 entries.

**What the host checks.** On marketplace install, `install:module`, enabling the module and every container start, InvoiceShelf refuses or disables a bundled module when an installed host package no longer satisfies a recorded constraint, a bundled package's PHP range excludes the running PHP, or a package, class or function in the bundle is already provided by the host or another module. A bundled package's Composer `files` entries run only while the module is enabled.

The module's tests run without a host, on Orchestra Testbench: `composer run test`.
**Releasing.** The release workflow takes `host-version`, the oldest InvoiceShelf release the module supports, builds `bundle/` from `bundle.lock` against that release's `composer.lock`, and ships it in the signed archive next to `dist/`. Newer hosts are checked against `host_packages` when they install it.

## Lifecycle and data removal

Disabling the module removes its page and sidebar entry and keeps its settings. Uninstalling removes the package. When an administrator ticks **Remove module data**, InvoiceShelf calls the module's `DataCleanup` hook first, runs every migration's `down()`, then removes the settings the host stored.
Disabling the module removes its page, sidebar entry and record slots and keeps its settings. Uninstalling removes the package. When an administrator ticks **Remove module data**, InvoiceShelf calls the module's `DataCleanup` hook first, runs every migration's `down()`, then removes the settings the host stored.

Hello World stores nothing of its own, so its cleanup is intentionally empty. A real cleanup must be safe to run twice and remove what reversible migrations do not cover: files, external resources, rows in shared tables.

## Releases

Releases are cut by pushing a tag equal to `module.json`'s `version` (unprefixed, such as `1.2.0`). The tag workflow uses the SDK's reusable workflow (`module-release.yml@3.4.0`) to validate, package, sign and register the release with the InvoiceShelf marketplace. See [CONTRIBUTING.md](CONTRIBUTING.md) for the checks to run first.
Releases are cut by pushing a tag equal to `module.json`'s `version` (unprefixed, such as `2.0.0`). The tag workflow uses the SDK's reusable workflow (`module-release.yml@3.5.0`) to validate, bundle, package, sign and register the release with the InvoiceShelf marketplace. See [CONTRIBUTING.md](CONTRIBUTING.md) for the checks to run first.
23 changes: 23 additions & 0 deletions app/Http/Controllers/QrCodeController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Modules\HelloWorld\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Routing\Controller;
use Modules\HelloWorld\Support\QrCode;

final class QrCodeController extends Controller
{
/** A QR code that opens Hello World on a phone. */
public function __invoke(): JsonResponse
{
$target = url('/admin/modules/hello-world/dashboard');

return response()->json([
'target' => $target,
'data_uri' => QrCode::svgDataUri($target),
]);
}
}
1 change: 1 addition & 0 deletions app/Providers/HelloWorldServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ public function boot(): void
parent::boot();

ModuleRegistration::register(module_path($this->name));
$this->loadRoutesFrom(module_path($this->name, 'routes/api.php'));
}
}
32 changes: 32 additions & 0 deletions app/Support/QrCode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Modules\HelloWorld\Support;

use chillerlan\QRCode\Output\QRMarkupSVG;
use chillerlan\QRCode\QRCode as QRCodeRenderer;
use chillerlan\QRCode\QROptions;

/**
* Renders a QR code with chillerlan/php-qrcode.
*
* The host does not ship that package, so Hello World bundles it in bundle/
* (see the README's "Dependency management"). Nothing here differs from
* using the package in any PHP project: the host loads bundled classes after
* its own, from bundle/classmap.php.
*/
final class QrCode
{
/** An SVG data URI an <img> can show directly. */
public static function svgDataUri(string $text): string
{
$options = new QROptions([
'outputInterface' => QRMarkupSVG::class,
'outputBase64' => true,
'addQuietzone' => true,
]);

return (new QRCodeRenderer($options))->render($text);
}
}
Loading
Loading