A new API package
Needs review
Needs review: The example-plugin tests were written but not run (the Docker stack was down). Run ./vendor/bin/phpunit packages/example-plugin/tests and the enable steps once.
Every API module of ulams is a package in api/packages/<name> with the namespace Ulams\<Name>\.
These packages are plain source directories (no composer.json of their own). The application
autoloads them from api/composer.json and registers their service providers explicitly in
api/config/app.php (how packages are wired).
This page walks through api/packages/example-plugin,
a minimal package written for this guide. The code shown here is read from the repository at
build time, and the package’s own tests cover it (run them). The package does two things:
GET /api/example-plugin/hello?name=Adareturns a greeting read from an administrable setting.POST /api/admin/example-plugin/greetingsis guarded by a permission and dispatches a domain event.
For larger real packages, follow bookmarks_notes (repositories and DTOs, owner policies, a
migration) or topic-type-project (a topic type with its own tables and events).
Layout
Section titled “Layout”Directoryapi/packages/example-plugin/
- README.md
Directorysrc/
- UlamsExamplePluginServiceProvider.php main provider
- config.php
- routes.php
DirectoryProviders/
- SettingsServiceProvider.php registers the setting
- Enums/ExamplePluginPermissionEnum.php
- Events/GreetingSent.php
DirectoryServices/
- Contracts/GreetingServiceContract.php
- GreetingService.php
DirectoryHttp/
DirectoryControllers/ controllers implement the Swagger interfaces
- …
- Requests/Admin/SendGreetingRequest.php
- Resources/GreetingResource.php
Directorydatabase/
- seeders/ExamplePluginPermissionSeeder.php
Directorymigrations/ (none here; see bookmarks_notes)
- …
Directorytests/
- TestCase.php
DirectoryApi/
- …
Service provider
Section titled “Service provider”The provider merges the config, binds services as singletons (contract to implementation),
registers the providers it depends on, and loads the routes. Packages with tables also call
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); most do this in bootForConsole(),
as UlamsBookmarksServiceProvider does. Policies go in a small Providers/AuthServiceProvider
that extends Laravel’s and lists $policies (see bookmarks_notes/src/Providers/AuthServiceProvider.php).
<?php
namespace Ulams\ExamplePlugin;
use Illuminate\Support\ServiceProvider;use Ulams\Auth\UlamsAuthServiceProvider;use Ulams\ExamplePlugin\Connectors\ExampleConnector;use Ulams\ExamplePlugin\Providers\SettingsServiceProvider;use Ulams\ExamplePlugin\Services\Contracts\GreetingServiceContract;use Ulams\ExamplePlugin\Services\GreetingService;use Ulams\LivingCourse\Connectors\SourceConnectorRegistry;
/** * Example module for the "Extending ulams" guide: a public endpoint that reads an * administrable setting and an admin endpoint guarded by a permission that dispatches a * domain event. Not registered in config/app.php; see README.md to enable it. * * SWAGGER_VERSION */class UlamsExamplePluginServiceProvider extends ServiceProvider{ public const CONFIG_KEY = 'ulams_example_plugin';
public $singletons = [ GreetingServiceContract::class => GreetingService::class, ];
public function register(): void { $this->mergeConfigFrom(__DIR__ . '/config.php', self::CONFIG_KEY);
$this->app->register(UlamsAuthServiceProvider::class); $this->app->register(SettingsServiceProvider::class); }
public function boot(): void { $this->loadRoutesFrom(__DIR__ . '/routes.php');
// a source connector for Living Course, only where Living Course is installed (docs/living-course/connector-plugins.md) if (class_exists(SourceConnectorRegistry::class)) { $this->app->afterResolving(SourceConnectorRegistry::class, fn (SourceConnectorRegistry $registry) => $registry->register(new ExampleConnector())); }
if ($this->app->runningInConsole()) { $this->publishes([ __DIR__ . '/config.php' => config_path(self::CONFIG_KEY . '.php'), ], self::CONFIG_KEY . '.config'); } }}Routes
Section titled “Routes”Public routes go under api/<package>, admin routes under api/admin/<package> with the
auth:api middleware (Passport). Authorization is not in the route: the FormRequest or a
policy does it.
<?php
use Illuminate\Support\Facades\Route;use Ulams\ExamplePlugin\Http\Controllers\Admin\GreetingAdminApiController;use Ulams\ExamplePlugin\Http\Controllers\HelloApiController;
Route::prefix('api/example-plugin')->group(function (): void { Route::get('hello', [HelloApiController::class, 'hello']);});
Route::prefix('api/admin/example-plugin') ->middleware(['auth:api']) ->group(function (): void { Route::post('greetings', [GreetingAdminApiController::class, 'send']); });Settings
Section titled “Settings”A package registers the config keys an admin may change with
AdministrableConfig::registerConfig($key, $rules, $public, $readonly) from the settings package.
Admins then change the keys per tenant with POST /api/admin/config, which validates the
new value with $rules. Public keys are returned to anyone by GET /api/config, which the
frontends load at boot. Values are stored in the tenant’s database (ulams_settings.use_database)
and override the package config. The tasks, video, jitsi and payments packages use the same
Providers/SettingsServiceProvider shape.
<?php
namespace Ulams\ExamplePlugin\Providers;
use Illuminate\Support\ServiceProvider;use Ulams\ExamplePlugin\UlamsExamplePluginServiceProvider;use Ulams\Settings\Facades\AdministrableConfig;use Ulams\Settings\UlamsSettingsServiceProvider;
class SettingsServiceProvider extends ServiceProvider{ public function register(): void { if (!class_exists(UlamsSettingsServiceProvider::class)) { return; }
if (!$this->app->getProviders(UlamsSettingsServiceProvider::class)) { $this->app->register(UlamsSettingsServiceProvider::class); }
// key, validation rules, public (returned by GET /api/config), read-only AdministrableConfig::registerConfig( UlamsExamplePluginServiceProvider::CONFIG_KEY . '.greeting', ['required', 'string', 'max:200'], true, false ); }}Never mark secrets as public. The payments package registers ulams_payments.drivers.stripe.secret_key
with $public = false.
Permissions
Section titled “Permissions”Permissions are Spatie permissions on the api guard. A package declares them in an enum and
seeds them onto roles. The roles are admin, tutor and student (Ulams\Core\Enums\UserRole).
api/database/seeds/PermissionsSeeder.php calls every package’s permission seeder explicitly,
and ulams:tenant:create runs it for each new tenant. Add your seeder to that list, and run it
once on each existing tenant.
<?php
namespace Ulams\ExamplePlugin\Enums;
use Ulams\Core\Enums\BasicEnum;
class ExamplePluginPermissionEnum extends BasicEnum{ public const SEND_GREETING = 'example-plugin_send-greeting';
public static function adminPermissions(): array { return [ self::SEND_GREETING, ]; }}<?php
namespace Ulams\ExamplePlugin\Database\Seeders;
use Illuminate\Database\Seeder;use Spatie\Permission\Models\Permission;use Spatie\Permission\Models\Role;use Ulams\Core\Enums\UserRole;use Ulams\ExamplePlugin\Enums\ExamplePluginPermissionEnum;
class ExamplePluginPermissionSeeder extends Seeder{ public function run(): void { $admin = Role::findOrCreate(UserRole::ADMIN, 'api');
foreach (ExamplePluginPermissionEnum::getValues() as $permission) { Permission::findOrCreate($permission, 'api'); }
$admin->givePermissionTo(ExamplePluginPermissionEnum::adminPermissions()); }}FormRequests and authorization
Section titled “FormRequests and authorization”Each endpoint has its own FormRequest. authorize() checks the permission, or calls a policy
with Gate::allows('update', $model) when the rule depends on the record (ownership, course
membership). rules() validates the input, and getters hand typed values to the controller.
<?php
namespace Ulams\ExamplePlugin\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;use Ulams\Core\Models\User;use Ulams\ExamplePlugin\Enums\ExamplePluginPermissionEnum;
/** * @OA\Schema( * schema="ExamplePluginSendGreetingRequest", * required={"user_id"}, * @OA\Property(property="user_id", description="Recipient", type="integer") * ) */class SendGreetingRequest extends FormRequest{ public function authorize(): bool { return (bool) $this->user()?->can(ExamplePluginPermissionEnum::SEND_GREETING); }
public function rules(): array { return [ 'user_id' => ['required', 'integer', 'exists:users,id'], ]; }
public function getRecipient(): User { return User::query()->findOrFail($this->validated('user_id')); }}Services, events and controllers
Section titled “Services, events and controllers”Controllers stay thin. They extend Ulams\Core\Http\Controllers\UlamsBaseController
(sendResponse, sendResponseForResource, sendError) and call a service bound by its
contract. The service holds the domain logic and dispatches events. When you change LMS entities
(courses, topics, progress), do it through the owning package’s repository or service, for
example TopicRepository or CourseProgressRepositoryContract, never with direct table writes.
<?php
namespace Ulams\ExamplePlugin\Services;
use Ulams\Core\Models\User;use Ulams\ExamplePlugin\Events\GreetingSent;use Ulams\ExamplePlugin\Services\Contracts\GreetingServiceContract;use Ulams\ExamplePlugin\UlamsExamplePluginServiceProvider;
class GreetingService implements GreetingServiceContract{ public function greeting(?string $name = null): string { $greeting = (string) config(UlamsExamplePluginServiceProvider::CONFIG_KEY . '.greeting');
return $name ? "{$greeting}, {$name}" : $greeting; }
public function send(User $user): string { $greeting = $this->greeting($user->first_name);
GreetingSent::dispatch($user, $greeting);
return $greeting; }}An event in the Ulams\ namespace that carries a User is also picked up by two wildcard
listeners: the notifications package stores it as a database notification, and the templates
package sends it on any channel that has a registered template. See
Events and listeners.
<?php
namespace Ulams\ExamplePlugin\Events;
use Illuminate\Foundation\Events\Dispatchable;use Illuminate\Queue\SerializesModels;use Ulams\Core\Models\User;
/** * Dispatched when an admin sends a greeting to a user. The class lives in the `Ulams\` * namespace and carries a User, so the notifications package stores it as a database * notification of that user and the templates package can send it on any channel an * admin has a template for. */class GreetingSent{ use Dispatchable, SerializesModels;
// Public so SerializesModels can restore them when the event is queued. public User $user;
public string $greeting;
public function __construct(User $user, string $greeting) { $this->user = $user; $this->greeting = $greeting; }
public function getUser(): User { return $this->user; }
public function getGreeting(): string { return $this->greeting; }}<?php
namespace Ulams\ExamplePlugin\Http\Controllers\Admin;
use Illuminate\Http\JsonResponse;use Ulams\Core\Http\Controllers\UlamsBaseController;use Ulams\ExamplePlugin\Http\Controllers\Admin\Swagger\GreetingAdminApiSwagger;use Ulams\ExamplePlugin\Http\Requests\Admin\SendGreetingRequest;use Ulams\ExamplePlugin\Http\Resources\GreetingResource;use Ulams\ExamplePlugin\Services\Contracts\GreetingServiceContract;
class GreetingAdminApiController extends UlamsBaseController implements GreetingAdminApiSwagger{ public function __construct(private GreetingServiceContract $greetings) { }
public function send(SendGreetingRequest $request): JsonResponse { $recipient = $request->getRecipient(); $greeting = $this->greetings->send($recipient);
return $this->sendResponseForResource(new GreetingResource($greeting, $recipient->getKey()), 'Greeting sent.'); }}Swagger annotations
Section titled “Swagger annotations”OpenAPI annotations (swagger-php @OA\... docblocks) sit on an interface that the controller
implements, in Http/Controllers/Swagger/. Request and resource schemas sit on the request and
resource classes. config/l5-swagger.php lists the directories it scans, one line per package
(base_path('packages/<name>/src')). Add yours there when you enable the package.
<?php
namespace Ulams\ExamplePlugin\Http\Controllers\Admin\Swagger;
use Illuminate\Http\JsonResponse;use Ulams\ExamplePlugin\Http\Requests\Admin\SendGreetingRequest;
interface GreetingAdminApiSwagger{ /** * @OA\Post( * path="/api/admin/example-plugin/greetings", * summary="Send the tenant's greeting to a user (dispatches GreetingSent)", * tags={"Admin Example plugin"}, * security={{"passport": {}}}, * @OA\RequestBody( * required=true, * @OA\JsonContent(ref="#/components/schemas/ExamplePluginSendGreetingRequest") * ), * @OA\Response( * response=200, * description="Successful operation", * @OA\JsonContent( * @OA\Property(property="success", type="boolean"), * @OA\Property(property="data", ref="#/components/schemas/ExamplePluginGreetingResource"), * @OA\Property(property="message", type="string") * ) * ), * @OA\Response(response=401, description="Not authenticated"), * @OA\Response(response=403, description="Missing the example-plugin_send-greeting permission"), * @OA\Response(response=422, description="Validation error") * ) */ public function send(SendGreetingRequest $request): JsonResponse;}Package tests extend the package’s own TestCase, which builds on Ulams\Core\Tests\TestCase
(Orchestra Testbench) and lists the providers the package needs in getPackageProviders. The
example registers its own provider there, which is why it can be tested without being enabled.
DatabaseTransactions rolls every test back. See Testing for the test
database.
<?php
namespace Ulams\ExamplePlugin\Tests;
use Illuminate\Foundation\Testing\DatabaseTransactions;use Laravel\Passport\Passport;use Laravel\Passport\PassportServiceProvider;use Spatie\Permission\PermissionServiceProvider;use Ulams\Auth\Models\User;use Ulams\Auth\Tests\Models\Client;use Ulams\Auth\UlamsAuthServiceProvider;use Ulams\Core\Enums\UserRole;use Ulams\Core\Tests\CreatesUsers;use Ulams\Core\Tests\TestCase as CoreTestCase;use Ulams\ExamplePlugin\Database\Seeders\ExamplePluginPermissionSeeder;use Ulams\ExamplePlugin\UlamsExamplePluginServiceProvider;use Ulams\Settings\Database\Seeders\PermissionTableSeeder as SettingsPermissionSeeder;use Ulams\Settings\UlamsSettingsServiceProvider;use Spatie\Permission\Models\Role;
/** * The provider is registered here, not in config/app.php: the package stays out of the * application until you enable it (README.md). */class TestCase extends CoreTestCase{ use CreatesUsers; use DatabaseTransactions;
protected function setUp(): void { parent::setUp(); Passport::useClientModel(Client::class); Role::findOrCreate(UserRole::STUDENT, 'api'); $this->seed(ExamplePluginPermissionSeeder::class); $this->seed(SettingsPermissionSeeder::class); }
protected function getPackageProviders($app): array { return [ ...parent::getPackageProviders($app), PassportServiceProvider::class, PermissionServiceProvider::class, UlamsAuthServiceProvider::class, UlamsSettingsServiceProvider::class, UlamsExamplePluginServiceProvider::class, ]; }
protected function getEnvironmentSetUp($app) { parent::getEnvironmentSetUp($app); $app['config']->set('auth.providers.users.model', User::class); $app['config']->set('passport.client_uuids', true); // the settings package caches the config: keep it in memory, away from a shared Redis $app['config']->set('cache.default', 'array'); $app['config']->set(UlamsExamplePluginServiceProvider::CONFIG_KEY . '.greeting', 'Hello from the tests'); }}Cover the happy path, 401 without a token, 403 without the permission, and 422 on invalid input. Fake the events you assert on.
<?php
namespace Ulams\ExamplePlugin\Tests\Api;
use Illuminate\Support\Facades\Event;use Ulams\ExamplePlugin\Events\GreetingSent;use Ulams\ExamplePlugin\Tests\TestCase;
class AdminGreetingApiTest extends TestCase{ public function testAnAdminSendsAGreetingAndTheEventIsDispatched(): void { Event::fake([GreetingSent::class]); $admin = $this->makeAdmin(); $student = $this->makeStudent(['first_name' => 'Ada']);
$this->actingAs($admin, 'api') ->postJson('/api/admin/example-plugin/greetings', ['user_id' => $student->getKey()]) ->assertOk() ->assertJsonPath('data.greeting', 'Hello from the tests, Ada') ->assertJsonPath('data.user_id', $student->getKey());
Event::assertDispatched( GreetingSent::class, fn (GreetingSent $event) => $event->getUser()->getKey() === $student->getKey() && $event->getGreeting() === 'Hello from the tests, Ada' ); }
public function testRequiresAuthentication(): void { $this->postJson('/api/admin/example-plugin/greetings', ['user_id' => 1])->assertUnauthorized(); }
public function testRequiresThePermission(): void { Event::fake([GreetingSent::class]); $student = $this->makeStudent();
$this->actingAs($student, 'api') ->postJson('/api/admin/example-plugin/greetings', ['user_id' => $student->getKey()]) ->assertForbidden();
Event::assertNotDispatched(GreetingSent::class); }
public function testValidatesTheRecipient(): void { $admin = $this->makeAdmin();
$this->actingAs($admin, 'api') ->postJson('/api/admin/example-plugin/greetings', ['user_id' => 0]) ->assertUnprocessable() ->assertJsonValidationErrors('user_id'); }}Tenant isolation test
Section titled “Tenant isolation test”Every new endpoint needs a tenant isolation test. Each tenant has its own database, APP_KEY
and Passport key pair (see Tenancy), so a token issued by one tenant
must be rejected by another. In one process and one test database, swapping the Passport keys
plays the second tenant, as packages/demo does. If your package signs its own tokens or hints
with APP_KEY (as lti, liascript and scorm do), also swap app.key and check that
values from the first tenant are rejected.
<?php
namespace Ulams\ExamplePlugin\Tests\Api;
use Illuminate\Support\Facades\Auth;use Illuminate\Support\Facades\Event;use Laravel\Passport\Bridge\AccessTokenRepository;use League\OAuth2\Server\AuthorizationServer;use League\OAuth2\Server\ResourceServer;use Ulams\ExamplePlugin\Events\GreetingSent;use Ulams\ExamplePlugin\Tests\TestCase;
/** * Tenants have separate databases and their own Passport key pair (ADR 0007), so a token * issued by one tenant must be rejected by another. Both "tenants" share this process and * test database here; switching the key pair is what tells them apart (the same technique * as packages/demo/tests/Api/DemoApiTest.php). */class TenantIsolationTest extends TestCase{ public function testAnAdminTokenOfAnotherTenantIsRejected(): void { Event::fake([GreetingSent::class]); $admin = $this->makeAdmin(); $student = $this->makeStudent(); $tokenA = $admin->createToken('test')->accessToken;
$this->send($tokenA, $student->getKey())->assertOk();
$this->switchToTenantWithOwnKeys();
$this->send($tokenA, $student->getKey())->assertUnauthorized(); Event::assertDispatchedTimes(GreetingSent::class, 1);
$tokenB = $admin->createToken('test')->accessToken; $this->send($tokenB, $student->getKey())->assertOk(); }
private function send(string $token, int $userId) { $response = $this->postJson('/api/admin/example-plugin/greetings', ['user_id' => $userId], ['Authorization' => 'Bearer ' . $token]); // the next request must authenticate from its own header only Auth::forgetGuards();
return $response; }
private function switchToTenantWithOwnKeys(): void { $key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); openssl_pkey_export($key, $private); $public = openssl_pkey_get_details($key)['key'];
config(['passport.private_key' => $private, 'passport.public_key' => $public]); foreach ([AuthorizationServer::class, ResourceServer::class, AccessTokenRepository::class] as $service) { $this->app->forgetInstance($service); } Auth::forgetGuards(); }}The opt-in end-to-end check against two real tenants is
packages/tenancy/tests/Integration/TenantIsolationTest.php.
Register the package
Section titled “Register the package”A new package needs these entries. The example has only the first one: it is kept out of the test suites and the application on purpose.
-
Autoload: PSR-4 entries in
api/composer.json. Runtime namespaces (src, seeders, factories) go inautoload.psr-4and tests inautoload-dev.psr-4. Then runcomposer dump-autoloadin the API container."Ulams\\ExamplePlugin\\": "packages/example-plugin/src","Ulams\\ExamplePlugin\\Database\\Seeders\\": "packages/example-plugin/database/seeders","Ulams\\ExamplePlugin\\Tests\\": "packages/example-plugin/tests", -
Test suite: a
<testsuite name="<name>">inapi/phpunit.xml, and the suite name in one shard ofPHPUNIT_SHARDSin.github/workflows/ci.yml. CI fails when a suite is in no shard or in two. -
Swagger: the
srcdirectory in the annotation paths ofapi/config/l5-swagger.php. -
Provider: the provider class in the “Package Service Providers” list of
api/config/app.php. Package discovery does not seeapi/packages. -
Permissions: the permission seeder in
api/database/seeds/PermissionsSeeder.php.
Enable the package
Section titled “Enable the package”-
Add
Ulams\ExamplePlugin\UlamsExamplePluginServiceProvider::classtoapi/config/app.php. -
Seed the permission on each tenant:
Terminal window php artisan db:seed --class="Ulams\ExamplePlugin\Database\Seeders\ExamplePluginPermissionSeeder" --domain=<slug>.localhost -
Call it:
Terminal window curl "http://coffee.localhost/api/example-plugin/hello?name=Ada"
For a package with migrations, run php artisan migrate --domain=<slug>.localhost for each
tenant. ulams:tenant:create runs migrations and PermissionsSeeder only for new tenants.
Run the tests
Section titled “Run the tests”docker compose -f api/docker-compose.yml exec api bash -c \ "DB_HOST=postgres DB_DATABASE=test DB_USERNAME=default DB_PASSWORD=secret ./vendor/bin/phpunit packages/example-plugin/tests"Upgrade steps
Section titled “Upgrade steps”When a release of your package needs data work that must run once for the platform and for every
tenant (backfilling a column, moving files, recreating a view), register a step from the service
provider. ulams:upgrade runs it per target and records it in tenant_upgrade_steps:
use Ulams\Tenancy\Upgrade\UpgradeContext;use Ulams\Tenancy\Upgrade\UpgradeSteps;
UpgradeSteps::register( 'bookmarks_backfill_kind', fn (UpgradeContext $context) => $context->artisan('bookmarks:backfill-kind'), since: '1.4',);The closure runs in the platform process for the platform and in a child
artisan --domain=<host> for a tenant, so put the work in an artisan command and call it through
$context->artisan(). Pass once: false for idempotent work that runs on every upgrade and
requiresCommand: '<command>' to skip the step in a build that lacks the command. See
Upgrades.