porto-sap/hello-api
Porto SAP Hello API is a Laravel-based starter framework for building scalable, modular APIs. It offers a clean architecture, built-in authentication and authorization, API documentation, validation, and code generators to accelerate enterprise API development.
Apiato (porto-sap/hello-api) is not a library you composer require into an existing app — it is an opinionated Laravel application skeleton built on the Porto architecture. Adoption means starting from the skeleton (or forking it) and living inside its conventions.
Minimal path to a working API:
composer create-project apiato/apiato my-api # or clone porto-sap/hello-api
cp .env.example .env
php artisan key:generate
# set DB_*, then:
php artisan migrate # Apiato wraps/extends migrate; picks up every container's Data/Migrations
php artisan db:seed
php artisan serve
Then open /docs — Apiato generates a live API Blueprint from controller annotations, so the docs page is effectively a map of every endpoint you have. As of v13.2.0 that docs page ships with a modern, responsive UI, so it is now genuinely usable from a phone or tablet — handy for skimming your endpoint surface during a code review or while away from the desk.
Look here first, in this order:
app/Ship/Parents/ — the base classes everything extends: Action, Task, Repository, Transformer, ApiController, ApiRequest, Policy, Middleware. Read Action and Repository before writing anything.app/Containers/AppSection/User (or Authentication). The folder is the documentation: Actions/, Tasks/, Data/{Models,Repositories,Migrations,Factories,Seeders,Criterias}, UI/API/{Controllers,Requests,Routes,Transformers}, Providers/.app/Ship/Configs/apiato.php and config/repository.php — request-criteria behaviour, pagination caps, hash-ID toggling.php artisan list apiato — the generator commands (apiato:generate:container, generate:action, generate:task, generate:request, generate:transformer, generate:controller, generate:repository, …). Scaffold rather than hand-roll; the generators wire providers and route files correctly.First real use case: add an endpoint to an existing container rather than creating a new one — a new method on User/UI/API/Controllers/Controller.php, a route line in UI/API/Routes/v1.php, a Request, an Action, and reuse of the existing UserTransformer. That teaches you the whole flow in one pass.
The request pipeline is fixed. Route → ApiRequest (validation) → thin controller → Action (one use case) → Task (reusable step) → Repository → Transformer. Controllers should contain no business logic and no Model::query() calls.
// app/Containers/AppSection/User/UI/API/Routes/v1.php
Route::get('users/{id}', [Controller::class, 'findUserById'])->name('api.user.find');
// UI/API/Controllers/Controller.php
public function findUserById($id, Request $request)
{
$user = $this->call(FindUserByIdAction::class, [$id]);
return $this->transform($user, UserTransformer::class);
}
// Actions/FindUserByIdAction.php
class FindUserByIdAction extends Action
{
public function __construct(private UserRepository $repository) {}
public function run(string $id): User
{
return $this->repository->find($id); // hash ID decoded by the repository/model trait
}
}
$this->call() and $this->transform() are the Apiato idioms for invoking Actions and rendering Fractal output — use them consistently and the codebase stays uniform.
Repositories do the querying, Criteria do the slicing. Every repository exposes a RequestCriteria that transparently honours query strings, so a list endpoint becomes $this->repository->paginate() and clients get:
GET /v1/users?search=john&filter=status:active&orderBy=created_at&sortedBy=desc&limit=20&include=roles
Control what is reachable via $fieldSearchable on the repository and by writing custom Criteria classes in Data/Criterias/. This is the single biggest day-to-day leverage point: you get search, filtering, sorting, eager-loading and pagination for free, but only for the fields you deliberately expose.
Transformers (Fractal) are your API contract. Put defaultIncludes for cheap relations and availableIncludes behind ?include=. Response shape changes belong here, never in controllers.
Validation lives in ApiRequest::rules(); authorization in authorize() and policies. Apiato's base request already produces the standard error envelope ({"errors":[{status, code, title, detail, source}]}), so don't hand-roll validation — throw App\Ship\Exceptions\ValidationException / NotFoundException / CreateResourceFailedException and the handler maps them to HTTP codes.
Auth flow. The Authentication container ships a proxy-login endpoint that returns access_token / refresh_token; protect routes with the auth:api middleware and resolve the caller with $request->user(). For ownership rules use Apiato policies in Ship/Parents/Policies and check them in the Action, not the controller.
Testing. Tests live beside the container they cover (UI/API/Tests/Functional, Tests/Unit) and are picked up by the root PHPUnit config. Functional tests hit real routes:
$user = $this->createTestingUser();
$this->get('api/v1/users/' . $user->getHashedKey())->assertStatus(200);
Factories (Data/Factories), seeders and the test helpers in tests/_support are what make containers independently testable — a container with no test folder is a container nobody will dare change.
Cross-container reuse: import another container's Action or Repository directly. Only push something into app/Ship/ when it is genuinely infrastructure (base classes, shared middleware, global config), not "shared business logic".
requires a starter skeleton, so there is no upgrade path except diffing your fork against upstream hello-api. Pin a tag, keep your divergence in containers only, and re-diff before each Laravel bump.13.1.0 → 13.2.0 and port or skip it deliberately. Do not treat a composer update-style bump as safe just because the changelog looks small; this project has no vendor copy to bump.UI/API/Controllers, a ServiceProvider outside Providers/, a route file that isn't Routes/v1.php — you get a missing route or an unresolvable binding with no error pointing at the layout. When something "doesn't exist", suspect the path before the code.$repository->find($id) decodes for you; raw where('id', $id) does not — you'll get empty results that look like a database bug. Decode explicitly when you leave the repository. Also: turning hash-id on after clients integrate silently breaks every stored ID.RequestCriteria is always listening. Any column added to $fieldSearchable is now client-filterable, and ?search= across relations can turn into a full table scan. Review $fieldSearchable as a security surface, not boilerplate. Cap ?limit in config('repository.pagination') — the default maximum is generous enough to OOM a small box.defaultIncludes fires a query per model unless the relation is eager-loaded upstream. Push the with() into the repository or a Criterion, then measure with ?include= on a large limit.\Exception gives you a generic 500 and a useless error envelope. Use the Ship exception classes so the handler can map them; register custom mappings in the exception handler rather than editing each Action.authorize() is easy to forget. A Request that validates perfectly and returns 201 does not prove authorization ran; write the 403 test explicitly. Same for route middleware — check php artisan route:list actually shows auth:api on the route you think it does.Languages/en/…) fall back to the key string on a typo; validation messages look "fine" until a user sees validation.custom.email./docs page is generated from @api* docblock annotations (apidoc under the hood). A malformed annotation drops the endpoint from the docs without failing the build — treat doc regeneration as part of CI, not a nice-to-have. The 13.2.0 UI refresh makes the page nicer to read but does not change the annotation parser: a broken docblock is still invisible on the page and in the build log.php artisan route:list | grep users to confirm registration, config('apiato.hash-id') / config('repository.*') to confirm runtime settings after a config:cache, and clear caches (optimize:clear) whenever behaviour contradicts the source — cached config and routes are the usual culprit in this skeleton.Criteria → custom exceptions/handler mappings → subclass a Ship/Parents base class → middleware in Ship/Middlewares → fork the base class itself. Since there's no vendor/ copy, forking a parent class is legitimate — just record it, because it's the thing that will make your next upstream diff painful. Same applies to the docs view: if product wants the responsive layout plus your branding, fork it knowingly and note the file list you diverged on.How can I help you explore Laravel packages today?