-
Identify scope. Decide whether this is an API endpoint (JSON, mounted under Router.php) or a web portal route (HTML, mounted under WebPortalRouter.php). API is the default for new endpoints. Verify by grepping the existing router file for similar routes before proceeding.
-
Pick a name and path. Controllers are PascalCase and end in Controller (e.g. LibraryController, PlaybackController). The file path is src/Server/Http/Controllers/<Name>Controller.php. Namespace is Jellyfin\\Server\\Http\\Controllers (or the project's existing controller namespace — confirm by reading any existing controller in that directory first). Verify the file does not already exist before proceeding to the next step.
-
Read one existing controller as the template. Open the most recently modified file in src/Server/Http/Controllers/ and copy its exact structure: namespace, use statements, constructor signature style, method signature (Request $request, array $params): Response, and Response chaining. Your new controller must match that file's conventions line-for-line on structure. This step's output (the imports + constructor pattern) is reused in Step 4.
-
Create the controller class. Using the template from Step 3, write the controller with:
-
Register the route. Open the correct router (Step 1):
- API:
src/Server/Http/Router.php
- Web portal:
src/Server/WebPortal/WebPortalRouter.php
Add the route inside the existing registration block, matching the surrounding style. Typical form:
$this->get('/api/library/{id}', [LibraryController::class, 'show']);
$this->post('/api/library', [LibraryController::class, 'store']);
Add a use statement for the controller at the top of the router file. Verify by grepping the router for the new path: it must appear exactly once.
-
Wire dependency injection. If the controller needs a service that is not already auto-wired, register the binding in the DI configuration (look for config/services.php, config/container.php, or wherever existing controllers' services are bound — search with grep -rn 'LibraryService::class' config/ src/). Skip this step only if the service is already a constructor-injectable class with no setup.
-
Add the unit test. Create tests/Unit/Server/Http/Controllers/<Name>ControllerTest.php extending PHPUnit\\Framework\\TestCase. Mock the injected service, instantiate the controller, call the method with a stub Request and $params array, and assert the returned Response's status and JSON body. Match the test style of the most recent test in tests/Unit/Server/Http/Controllers/ (or tests/Unit/ if that subdir is empty). Run: vendor/bin/phpunit tests/Unit/Server/Http/Controllers/<Name>ControllerTest.php — must be green before the task is complete.
-
Smoke-test the route. Boot the server (look in README.md or composer.json scripts for the start command — commonly php -S localhost:8096 -t public/ or composer serve). Hit the new endpoint with curl -i http://localhost:8096/api/<path> and confirm the status code and JSON body match expectations. If 404, re-check Step 5 — the router didn't pick up the route.
-
404 Not Found from curl but the controller class exists. The route was registered in the wrong router. API routes belong in src/Server/Http/Router.php. If you put it in WebPortalRouter.php, it will only resolve under the web portal mount. Move the registration and re-test.
-
Class "LibraryController" not found when the router boots. Missing use statement at the top of the router file, or the class namespace doesn't match the directory. Run composer dump-autoload then verify the namespace declaration in your controller matches PSR-4 mapping in composer.json.
-
Argument #1 ($library) must be of type LibraryService, null given. The DI container isn't wiring the service. Check the container config (Step 6) and ensure the service is registered. Confirm with: grep -rn 'LibraryService' config/ src/Server/ — there must be a binding outside the controller itself.
-
Call to undefined method Response::json(). You imported the wrong Response class (likely a stdlib or third-party one). The correct import is Jellyfin\\Server\\Http\\Response (or whatever the existing controllers use — verify by grep -h 'use.*Response' src/Server/Http/Controllers/*.php | sort -u).
-
$params['id'] is always null even though the URL has the id segment. The route pattern in the router uses a different placeholder name than what you're reading. If the route is /api/library/{itemId}, read $params['itemId'], not $params['id']. Names must match exactly.
-
Test passes but real curl request returns 500. The test mocked the service, but the real DI binding throws on construction. Reproduce locally without mocks by booting the server and tailing logs/ (or wherever config/ points logging) for the stack trace. Fix the binding, do not silence the test.
-
Headers already sent warning in production response. Something echo'd or print'd before the Response was returned. Search the controller and any service it calls for stray output: grep -rn 'echo\\|print\\|var_dump' src/Server/Http/Controllers/ src/<service-dir>/.