Skip to content

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=Ada returns a greeting read from an administrable setting.
  • POST /api/admin/example-plugin/greetings is 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).

  • 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/
        • …

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).

src/UlamsExamplePluginServiceProvider.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');
}
}
}

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.

src/routes.php
<?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']);
});

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.

src/Providers/SettingsServiceProvider.php
<?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 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.

src/Enums/ExamplePluginPermissionEnum.php
<?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,
];
}
}
database/seeders/ExamplePluginPermissionSeeder.php
<?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());
}
}

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.

src/Http/Requests/Admin/SendGreetingRequest.php
<?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'));
}
}

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.

src/Services/GreetingService.php
<?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.

src/Events/GreetingSent.php
<?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;
}
}
src/Http/Controllers/Admin/GreetingAdminApiController.php
<?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.');
}
}

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.

src/Http/Controllers/Admin/Swagger/GreetingAdminApiSwagger.php
<?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.

tests/TestCase.php
<?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.

tests/Api/AdminGreetingApiTest.php
<?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');
}
}

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.

tests/Api/TenantIsolationTest.php
<?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.

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.

  1. Autoload: PSR-4 entries in api/composer.json. Runtime namespaces (src, seeders, factories) go in autoload.psr-4 and tests in autoload-dev.psr-4. Then run composer dump-autoload in 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",
  2. Test suite: a <testsuite name="<name>"> in api/phpunit.xml, and the suite name in one shard of PHPUNIT_SHARDS in .github/workflows/ci.yml. CI fails when a suite is in no shard or in two.

  3. Swagger: the src directory in the annotation paths of api/config/l5-swagger.php.

  4. Provider: the provider class in the “Package Service Providers” list of api/config/app.php. Package discovery does not see api/packages.

  5. Permissions: the permission seeder in api/database/seeds/PermissionsSeeder.php.

  1. Add Ulams\ExamplePlugin\UlamsExamplePluginServiceProvider::class to api/config/app.php.

  2. Seed the permission on each tenant:

    Terminal window
    php artisan db:seed --class="Ulams\ExamplePlugin\Database\Seeders\ExamplePluginPermissionSeeder" --domain=<slug>.localhost
  3. 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.

Terminal window
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"

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.