🌐 Language: 🇻🇳 Tiếng Việt · 🇬🇧 English (current) · 🇯🇵 日本語
How to build an API correctly on GP247
Introduction
This document explains how to build (write) a new API endpoint the GP247 way — for developers who want to extend a GP247 site with their own APIs (e.g. feeding a mobile app, an external management tool, or a third-party integration). By the end you will know where to put the code, how to declare the route, and how controllers/middleware/authentication fit together, with a complete copy-and-run example. If you only want to consume existing APIs (not write new ones), read GP247 API Introduction first.
ℹ️ GP247's golden rule: never modify core code (
vendor/gp247/*). To add your own API for a specific site, do it in a plugin (app/GP247/Plugins/) or via the override mechanism underapp/GP247/. Only add directly tocore/front/shopwhen you are the primary maintainer of that package.
1. Understand the API architecture first
A GP247 API is assembled from a few existing pieces. Understanding them lets you plug your code in at the right place.
1.1. The two groups and their prefixes
Every new endpoint goes under the v2 prefixes:
| Group | Constant used in routes | Value | Used for |
|---|---|---|---|
| Core (Admin) | GP247_API_V2_CORE_PREFIX |
api/v2/core |
System administration (requires an admin token) |
| Front (Storefront) | GP247_API_V2_FRONT_PREFIX |
api/v2/front |
Storefront data & customer accounts |
Both constants are fixed in vendor/gp247/core/src/Library/Const.php. Always use the constants in
routes (never a hand-typed "api/v2/core" string) so that if the prefix ever changes, your code follows
automatically.
⚠️ REST API v1 is frozen. Since gp247/core, gp247/front, gp247/shop 3.3.0, API v1 (
GP247_API_CORE_PREFIX=api/core,GP247_API_FRONT_PREFIX=api/front) gets no new features — security fixes only; fresh installs since gp247/core 3.3.1 have v1 switched off. Existing v1 endpoints keep working, but do not add new endpoints there. See API v1.
1.2. Enable API mode
The API only works when the GP247_API_MODE flag is on. Check the site's .env file:
GP247_API_MODE=true
If this flag is off, none of the API routes are loaded at all (see vendor/gp247/core/src/routes.php).
With the flag on, the site owner can still switch each group on or off on the API management screen (the
api.enabled layer, see API Introduction, section 5). Routes under api/v2/front follow
the "v2 · Public & customer API (front)" switch, routes under api/v2/core the "v2 · Admin API (core)"
switch — routes added by a plugin included. A group that is off answers 404 with code: api_disabled.
1.3. How API routes are auto-loaded
When GP247_API_MODE=true, each package automatically scans and loads every file in the following folders
and wraps them all in the GP247_API_MIDDLEWARE group:
vendor/gp247/core/src/Routes/Api/v2/*.phpvendor/gp247/shop/src/Routes/Api/v2/*.phpvendor/gp247/front/src/Routes/Api/v2/*.php
(The v1 files live in Routes/Api/*.php of the same package — kept only for the existing endpoints.)
GP247_API_MIDDLEWARE = ['api', 'api.request_id', 'json.response', 'api.enabled', 'api.extend'], where api.extend is the GP247 middleware group defined
in config('gp247-config.api.middleware'):
| Order | Middleware | Role |
|---|---|---|
| 1 | json.response |
Forces every response to be JSON (sets header Accept: application/json). Also part of GP247_API_MIDDLEWARE and runs before every other middleware (auth:* included), so it stays even when a published config lacks this line |
| 2 | api.connection |
Optional application-level gate (apiconnection/apikey) — toggled by config |
| 3 | throttle:gp247-api |
Rate limit, by default 1000 requests/minute per IP (GP247_API_THROTTLE) |
⚠️ This order is fixed — do not change it. It is a convention stated explicitly in the config.
ℹ️ Available since: gp247/core 3.6.0 —
json.responseruns beforeauth:*: a missing token withoutAccept: application/jsonstill gets a JSON401(before:500).👉 Important consequence for plugins: routes placed in the
Routes/Api/v2/folder of core/shop/front get these three layers automatically. But routes you add from a plugin are loaded elsewhere (section 4), so they do not get them automatically — you must wrap them yourself, or (since 3.3.0) register them throughApiRouteRegistrar(section 4.1).
ℹ️ Available since: gp247/core, gp247/front, gp247/shop 3.3.0 — each package registers its own API routes (including the
Routes/Api/v2/folder) throughGP247\Core\Api\Support\ApiRouteRegistrar; a package's routes only exist once the package has been installed into the database. Paths and route names are unchanged.
1.4. Authentication with Laravel Sanctum
GP247 uses Sanctum (Bearer tokens). There are two separate "guards" (login gates):
| Guard | Provider | For | Where the token is issued |
|---|---|---|---|
admin-api |
admin_provider |
Administrators | api/v2/core/login |
customer-api |
customer_provider |
Customers/members | api/v2/front/login |
Each token also carries a scope/ability that limits what it can do. Scopes come from
config('gp247-config.api.auth') (overridable via .env):
| Config key | Default value | Meaning |
|---|---|---|
api_scope_admin |
admin |
Regular admin |
api_scope_admin_supper |
admin-supper |
Super admin (administrator role) |
api_scope_user |
user |
Activated member |
api_scope_user_guest |
user-guest |
Non-activated member/guest |
To protect a route that requires login, use this pair of middleware:
'middleware' => [
'auth:admin-api', // or 'auth:customer-api' for front APIs
\GP247\Core\Api\Support\ApiAbilities::admin(), // or ApiAbilities::customer(): the allowed scopes
]
1.5. The gp247_namespace() helper — the override hook
In GP247 routes, a controller is not referenced directly as Controller::class; it is wrapped in
gp247_namespace():
$catalog = gp247_namespace(\GP247\Shop\Api\V2\CatalogController::class);
Route::get('product/list', $catalog.'@productList');
This helper checks: if an override file exists at app/GP247/..., it uses the app's version, otherwise
it uses the vendor version. This is how end users can override a controller without touching vendor.
Always use this helper when pointing a route at a controller from a GP247\... package.
2. Naming conventions & response format
Follow these conventions so your new API stays consistent with the rest of the system.
-
Group by resource: each data group is one
Route::group(['prefix' => 'product'], ...). -
Two standard actions:
list(collection) anddetail/{id}(a single record). -
Controller method names:
<resource>Listand<resource>Detail(e.g.productList,productDetail— like the v2 controllers of the shop package). -
Always return JSON through the
GP247\Core\Api\V2\ApiQueryhelper (it returns aJsonResponse), orresponse()->json(...)for a response that is not a record. -
On success: wrap the data in
data—{"data": …}for one record (ApiQuery::one()), and{"data": [...], "meta": {...}, "links": {...}}for a list (ApiQuery::page()). -
On failure/not found: use
ApiErrorResponderso you get a meaningful HTTP status code and the shared error body{"error": 1, "msg": "…", "code": "…"}(code table in API Introduction, section 6).ApiQuery::one()returns404by itself when there is no record:use GP247\Core\Api\Support\ApiErrorResponder; if (!$product) { return ApiErrorResponder::notFound(); // 404, code: not_found }ℹ️ Available since: gp247/core 3.2.3 — authentication, authorization, validation and rate-limit errors under
api/*are converted to this shape automatically; you only need to returnnotFound()/forbidden()yourself when the controller makes that decision. The old patternresponse()->json([...], 200)for errors is no longer used. -
Return only the fields you need: the
$toArrayfunction you pass toApiQuerydeclares exactly the fields returned, instead of returning the whole model — new columns added to the table will not leak into the API automatically. For public data, build the base query withGP247\Core\Api\Support\ApiVisibility::visible(...)so the API returns exactly the records the website shows. -
Pagination, filtering, sorting: use
ApiQuery::page($query, $request, $toArray, $filters, $sorts, $search)instead of->get()when returning a list. The client drives it withpage[number],page[size](default 20, max 100),filter[<name>],sort=<name>/sort=-<name>andq(search). Only the filter/sort names you declare are accepted — an unknown name returns422(validation_failed); client input never becomes a column name in the query. Multilingual content: get the language withApiQuery::lang($request)and the description fields withApiQuery::described(...).
3. Example A — Add an endpoint to a package you maintain (core/front/shop)
Use this when you are the package maintainer (editing directly in vendor/gp247/<package>), e.g.
adding a public front API api/v2/front/review/list. Follow the exact order:
-
Create the controller under
vendor/gp247/<package>/src/Api/V2/.... For front data, extendRootFrontController; for admin data, extendRootAdminController.Create
src/Api/V2/ReviewController.php:<?php namespace GP247\Shop\Api\V2; use GP247\Core\Api\V2\ApiQuery; use GP247\Front\Controllers\RootFrontController; use GP247\Shop\Models\ShopProductReview; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; class ReviewController extends RootFrontController { /** * Get the public review list. * * @return \Illuminate\Http\JsonResponse */ public function reviewList(Request $request) { $query = ShopProductReview::query()->where('status', 1); return ApiQuery::page($query, $request, fn ($r) => self::review($r), ['product_id' => fn (Builder $q, string $v) => $q->where('product_id', $v)], // filter[product_id] ['created_at' => 'created_at']); // sort=created_at | -created_at } /** * Get one public review (404 when it does not exist). * * @return \Illuminate\Http\JsonResponse */ public function reviewDetail(Request $request, $id) { $review = ShopProductReview::query()->where('status', 1)->where('id', $id)->first(); return ApiQuery::one($review, fn ($r) => self::review($r)); } /** * Declared shape of a review: only these fields reach the API. */ private static function review($r): array { return [ 'id' => $r->id, 'product_id' => $r->product_id, 'point' => (int) $r->point, 'comment' => $r->comment, 'created_at' => optional($r->created_at)->toIso8601String(), ]; } } -
Create the route file at
src/Routes/Api/v2/review.php(just placing it in theRoutes/Api/v2/folder is enough — the package loads it automatically, no registration needed anywhere else):<?php use GP247\Shop\Api\V2\ReviewController; use Illuminate\Support\Facades\Route; Route::group([ 'prefix' => GP247_API_V2_FRONT_PREFIX, // => api/v2/front ], function () { $reviewController = gp247_namespace(ReviewController::class); Route::group(['prefix' => 'review'], function () use ($reviewController) { Route::get('list', $reviewController.'@reviewList'); Route::get('detail/{id}', $reviewController.'@reviewDetail'); }); }); -
Verify the route is loaded with an Artisan command (run from the site root):
php artisan route:list --path=api/v2/front/reviewOn success you will see a
GET | api/v2/front/review/listline. If you don't, re-checkGP247_API_MODE=truein.envand clear the route cache:php artisan route:clear. -
Try calling it (a public endpoint, so no token needed;
-gkeeps curl from interpreting[ ]):curl -g -X GET "https://shop.example.com/api/v2/front/review/list?page[size]=10&sort=-created_at" -H "Accept: application/json"Expected result:
{ "data": [ { "id": 12, "product_id": 5, "point": 5, "comment": "…", "created_at": "2026-10-30T10:00:00+00:00" } ], "meta": { "current_page": 1, "per_page": 10, "total": 1, "last_page": 1 }, "links": { "first": "…", "last": "…", "prev": null, "next": null } }
For an admin endpoint (login required), put the route in the package's
src/Routes/Api/v2/and wrap it in theauth:admin-api+ApiAbilities::admin()middleware group withprefix => GP247_API_V2_CORE_PREFIX, each route declaringapi.admin:<admin route name>(section 5.1; see the template atvendor/gp247/shop/src/Routes/Api/v2/admin.php).
4. Example B — Add an API from a plugin (recommended for site-specific APIs)
This is the correct way to add an API for a specific site when you must not touch vendor. A
plugin lives at app/GP247/Plugins/<PluginName>/, and its Route.php file is loaded by core
automatically.
⚠️ Key difference: plugin routes are loaded outside the API middleware group (section 1.3), so you must wrap them yourself with
GP247_API_MIDDLEWAREand set theprefixyourself. If you forget, your API will not force JSON, not pass through theapi.connectionlayer, not follow the API on/off switches, and not have any rate limit.
-
Create the controller in the plugin, e.g.
app/GP247/Plugins/MyApi/Api/Front/StatusController.php:<?php namespace App\GP247\Plugins\MyApi\Api\Front; use GP247\Front\Controllers\RootFrontController; use Illuminate\Http\Request; class StatusController extends RootFrontController { /** * Return a simple health-check payload for external monitors. * * @return \Illuminate\Http\JsonResponse */ public function status(Request $request) { return response()->json(['data' => [ 'status' => 'ok', 'time' => gp247_time_now(), ]]); } } -
Declare the route in
app/GP247/Plugins/MyApi/Route.php, wrapping the API middleware group yourself:<?php use App\GP247\Plugins\MyApi\Api\Front\StatusController; use Illuminate\Support\Facades\Route; // Only register when the API mode is on, matching core behaviour. if (config('gp247-config.env.GP247_API_MODE')) { Route::group([ 'middleware' => GP247_API_MIDDLEWARE, // required: plugin routes load outside the API group 'prefix' => GP247_API_V2_FRONT_PREFIX, // => api/v2/front ], function () { $statusController = gp247_namespace(StatusController::class); Route::group(['prefix' => 'myapi'], function () use ($statusController) { Route::get('status', $statusController.'@status'); }); }); } -
Clear the cache and verify the route:
php artisan route:clear php artisan route:list --path=api/v2/front/myapiOn success you will see
GET | api/v2/front/myapi/status. -
Try calling it:
curl -X GET "https://shop.example.com/api/v2/front/myapi/status" -H "Accept: application/json"Expected result:
{ "data": { "status": "ok", "time": "2026-07-30 10:00:00" } }
4.1. A simpler way: register through ApiRouteRegistrar
ℹ️ Available since: gp247/core 3.3.0
Instead of wrapping the middleware group yourself in Route.php, put the API routes in a separate file of the
plugin and register it from the plugin's Provider.php:
// app/GP247/Plugins/MyApi/Provider.php
\GP247\Core\Api\Support\ApiRouteRegistrar::register(__DIR__ . '/Routes/api.php');
// app/GP247/Plugins/MyApi/Routes/api.php — declare only the prefix and the routes
use Illuminate\Support\Facades\Route;
Route::group(['prefix' => GP247_API_V2_FRONT_PREFIX], function () {
$statusController = gp247_namespace(\App\GP247\Plugins\MyApi\Api\Front\StatusController::class);
Route::get('myapi/status', $statusController.'@status');
});
ApiRouteRegistrar wraps GP247_API_MIDDLEWARE for you (JSON, API Connection, rate limit), registers nothing
when GP247_API_MODE is off, and does not register the routes again when the site has run route:cache. The
{error, msg, code} error contract applies to every path under api/core, api/front, api/v2/core and
api/v2/front; a route under a prefix of its own (outside these four) keeps Laravel's default error bodies and
follows only the master switch of the API screen.
5. Create an endpoint that requires login (protected by a token)
If your API returns private data (a customer's orders, admin data…), you must wrap it in the matching
Sanctum guard. In the controller, get the logged-in user via $request->user().
Example of a customer route that requires login (api.customer.active, from the shop package, refuses the token of
an account locked after the token was issued):
use GP247\Core\Api\Support\ApiAbilities;
Route::group([
'prefix' => GP247_API_V2_FRONT_PREFIX, // => api/v2/front
'middleware' => ['auth:customer-api', ApiAbilities::customer(), 'api.customer.active'],
], function () {
$ctrl = gp247_namespace(\App\GP247\Plugins\MyApi\Api\Front\WishlistController::class);
Route::get('member/wishlist/list', $ctrl.'@wishlistList');
});
For admin routes, see section 5.1.
The client must send the token obtained from login in the header:
Authorization: Bearer <access_token>
Issuing tokens (
createToken) and revoking tokens (logout) are already implemented in the core/shop login controllers. You just need to reuse theapi/v2/core/login/api/v2/front/loginendpoints to obtain a token (v2 also offerswith_refreshto get a refresh token,login/challengefor the sign-in verification step, andrefresh— see API Introduction, section 2) — no need to rewrite the login part unless you have a special requirement.
5.1. Admin endpoints: declare the admin screen and scope by store
ℹ️ Available since: gp247/core 3.2.3
An admin token does not automatically allow viewing all data. Every route under
GP247_API_V2_CORE_PREFIX must declare its matching admin screen with the
api.admin:<admin route name> middleware; GP247 only allows the call when the account has permission to
open that screen (the same permissions as the admin panel), applies the guards of the multi-store
plugins, and resolves the account's store:
Route::group([
'prefix' => GP247_API_V2_CORE_PREFIX, // => api/v2/core
'middleware' => [
'auth:admin-api',
\GP247\Core\Api\Support\ApiAbilities::admin(), // since 3.3.0
],
], function () {
$ctrl = gp247_namespace(\App\GP247\Plugins\MyApi\Api\Admin\ReportController::class);
Route::get('report/sales', $ctrl.'@salesList')
->middleware('api.admin:admin_report.sales'); // route name of the matching admin screen
});
In the controller, always filter the query by the account's store:
use GP247\Core\Api\Support\ApiStoreScope;
use GP247\Core\Api\V2\ApiQuery;
return ApiQuery::page(ApiStoreScope::apply(ShopOrder::query()), $request,
fn ($o) => ['id' => $o->id, 'total' => $o->total, 'status' => (int) $o->status]);
Calling ApiStoreScope on a route without api.admin raises an error (safe by default) — this is how
GP247 reminds you that you forgot to declare the permission.
5.2. Admin write endpoints, sync-ready lists and events
ℹ️ Available since: gp247/core 3.7.0
This part is for plugins that want an ERP or sync tool to write the plugin's data, read its changes with
updated_since and receive the plugin's events — the same mechanisms the built-in endpoints use (how an application
calls them: API Introduction, sections 6 and 7).
a) Admin write endpoints. Declare api.admin:<admin screen route name>,write: the call is allowed only when the account
may change data on that screen — checked exactly like the screen's own actions; a view-only account gets 403. Add two
more middleware:
| Middleware | Effect |
|---|---|
throttle:gp247-api-write |
Write limit per admin account, by default 300 requests / minute (environment variable GP247_API_WRITE_THROTTLE, as requests,minutes); over the limit → 429 |
api.idempotent |
The Idempotency-Key header is required (missing → 400 idempotency_key_required). A retry with the same key and body answers the first result with the header Idempotent-Replayed: true, without running again; the same key with another body → 409 idempotency_key_reused |
api.idempotent:optional |
The key is accepted when sent, not required. Use api.idempotent for commands that would be wrong if run twice (for example adding to or removing from stock) |
Route::group([
'prefix' => GP247_API_V2_CORE_PREFIX, // => api/v2/core
'middleware' => [
'auth:admin-api',
\GP247\Core\Api\Support\ApiAbilities::admin(),
'api.admin:admin_report.sales,write', // a write on the matching admin screen
'throttle:gp247-api-write',
'api.idempotent:optional',
],
], function () {
$ctrl = gp247_namespace(\App\GP247\Plugins\MyApi\Api\Admin\ReportWriteController::class);
Route::post('report/{id}/status', $ctrl.'@status');
});
In the controller: find the record through ApiStoreScope (outside the account's store → 404), accept only an allowlist
of fields (refuse unknown keys, never drop them silently), and change state through the model / business methods — not raw
SQL:
use GP247\Core\Api\Support\ApiErrorResponder;
use GP247\Core\Api\Support\ApiStoreScope;
use GP247\Core\Api\V2\ApiQuery;
/**
* `POST report/{id}/status` — change the status of one report.
*/
public function status(Request $request, $id)
{
$report = ApiStoreScope::apply(MyReport::query())->where('id', (string) $id)->first();
if ($report === null) {
return ApiErrorResponder::notFound(); // 404: not in the account's store
}
$rules = ['status' => 'required|integer|in:0,1'];
foreach (array_diff(array_keys($request->all()), array_keys($rules)) as $unknown) {
$rules[$unknown] = 'prohibited'; // unknown keys → 422, never silently dropped
}
$report->changeStatus((int) $request->validate($rules)['status']); // business method, not raw SQL
return ApiQuery::one($report->fresh(), fn ($r) => ['id' => $r->id, 'status' => (int) $r->status]);
}
b) Sync-ready lists. Pass changedSince: true to ApiQuery::page() — the list then also accepts updated_since and
pages with the page[after] cursor (in that mode sort and page[number] are refused with 422):
return ApiQuery::page(ApiStoreScope::apply(MyReport::query()), $request,
fn ($r) => ['id' => $r->id, 'status' => (int) $r->status],
['status' => fn (Builder $q, string $v) => $q->where('status', $v)], // filter[status]
['created_at' => 'created_at'], // sort (normal mode only)
changedSince: true);
- The table needs an
updated_atcolumn (an index on it is recommended). - Every write must move
updated_atforward — saving through Eloquent (save()/update()) does it for you; a raw SQL or query-builder update leaves the record out of theupdated_sincelist.
c) Plugin event types. Declare them in the config gp247-config.api.events, as
'<prefix>.<resource>.<action>' => ['fields' => [...], 'permission' => '<admin screen route name>']. Merge into the
existing array, never overwrite it (core and the shop package register their types there too):
// In the plugin's service provider (boot)
config(['gp247-config.api.events' => array_merge((array) config('gp247-config.api.events', []), [
'myapi.report.status_changed' => [
'fields' => ['from', 'to'], // the only keys ever kept in data
'permission' => 'admin_report.sales', // admins allowed to open this screen may read the event
],
])]);
-
Names are lower case, segments separated by dots, at most 64 characters; a plugin prefixes with its own key so it never clashes with other types. A malformed name is ignored.
-
Record an event with
\GP247\Core\Api\Sync\ApiEvents::record($type, $subjectId, $storeId, $data):\GP247\Core\Api\Sync\ApiEvents::record('myapi.report.status_changed', (string) $report->id, (string) $report->store_id, ['from' => $old, 'to' => (int) $report->status]); -
Only the fields declared in
fieldsare kept; never put personal data indata(an ERP that needs more reads the record again through its detail endpoint). Core fills insourceandactor. -
The event is recorded after the surrounding transaction commits — a rolled-back transaction leaves no event. While the site owner keeps the event log off (or the site has not run
php artisan gp247:update), nothing is recorded. A failure to record is only reported to the log and never thrown — the plugin's change cannot break because of the log. -
Recorded events also reach the webhooks following that type; a new type shows up by itself in the webhook's event type picker.
d) To have the shop's own events for your plugin's changes (orders, stock, products, customers): save through the shop
package's models (Eloquent save() / update() / delete()) or its methods. A change written with raw SQL or a bare
query-builder update() is not seen — the ERP will not notice it.
5.3. Choosing fields, including relations, caching and deprecation
ℹ️ Available since: gp247/core 3.8.0
Your endpoint already gets ETag / 304 on every successful v2 GET through GP247_API_MIDDLEWARE — nothing to do. The
other three depend on the endpoint:
fields/include: declare anApiShape(type name, fields, relations that can be included) and pass the choice toApiQuery::page()/ApiQuery::one(). When a field mapped to a relation is not chosen,$shape->with()skips loading that relation — your$toArraymust not read the relation of a dropped field (useApiShapeSelection::build()with closures for costly values). Included relations must follow the same visibility rules as when read on their own.api.cache.public: only for public routes that never carry one person's own data (catalog, content). Never on the cart, accounts, orders or the admin API.api.deprecated:<deprecated-on>,<sunset-on>,<guide anchor>: on a route you plan to remove; give at least 12 months and 2 minor releases of notice.
use GP247\Core\Api\V2\ApiQuery;
use GP247\Core\Api\V2\ApiShape;
// Declare the fields of the resource; a field mapped to relations lets ApiShape skip loading them.
$shape = (new ApiShape('myapi_item', ['name' => ['descriptions'], 'price', 'status'], [
'brand' => [
'with' => ['brand' => fn ($q) => $q->where('status', 1)],
'render' => fn ($item) => $item->brand ? ['id' => $item->brand->id, 'name' => $item->brand->name] : null,
'type' => 'brand',
'fields' => ['id', 'name'],
],
]))->resolve($request); // 422 for an unknown type, field or include
$query = MyItem::query()->with($shape->with(['descriptions']));
return ApiQuery::page($query, $request, fn ($item) => [
'id' => $item->id, 'name' => $item->descriptions->first()?->name, 'price' => (float) $item->price, 'status' => (int) $item->status,
], [], [], null, false, $shape);
// Routes/api.php — a public route without anyone's own data may be cached by browsers / a CDN
Route::get('myapi/items', $controller.'@index')->middleware('api.cache.public');
// A route you plan to remove: Deprecation + Sunset + Link headers on every answer
Route::get('myapi/old-items', $controller.'@old')->middleware('api.deprecated:2026-11-01,2027-12-31,myapi-items');
6. Checklist before calling it "done"
- The new endpoint is under v2 — use the constants
GP247_API_V2_CORE_PREFIX/GP247_API_V2_FRONT_PREFIX, no hardcoded strings, nothing added to v1. - Reference controllers through
gp247_namespace(...)to allow overrides. - Plugin routes wrap
GP247_API_MIDDLEWARE+prefixthemselves (section 4) or register throughApiRouteRegistrar(section 4.1). - Every input has
$request->validate([...]); no string-concatenated SQL (use Eloquent). - Lists use
ApiQuery::page()(declaring only thefilter/sortnames you need); one record usesApiQuery::one(). - Private endpoints have
auth:admin-api+ApiAbilities::admin(), orauth:customer-api+ApiAbilities::customer()+api.customer.active. - Admin endpoints have
api.admin:<admin route name>and filter data withApiStoreScope::apply()(section 5.1). - Admin write endpoints have
api.admin:<admin route name>,write+throttle:gp247-api-write+api.idempotent(orapi.idempotent:optional); the controller finds the record throughApiStoreScope, refuses unknown fields and changes state through the model / business methods (section 5.2). - Lists meant for sync use
ApiQuery::page(..., changedSince: true); the table has anupdated_atcolumn (indexed) and every write updates it (section 5.2). - Plugin event types are merged into
gp247-config.api.events(prefixed with the plugin key, withfieldsandpermission), recorded withApiEvents::record(), with no personal data indata(section 5.2). - Changes to shop data are saved through the shop package's models / methods (no raw SQL) so the ERP gets the events (section 5.2).
- Errors go through
ApiErrorResponder(meaningful HTTP status codes); the$toArrayfunction returns only the declared fields. - Do not modify
vendor/gp247/*(unless you are the maintainer of that package). - Every method has an English PHPDoc.
- You ran
php artisan route:clearand confirmed the route appears inphp artisan route:list. - A plugin adding a sign-in step implements
LoginChallengeand returns no secret fromdescribe()(section 7). - Lists/details declare an
ApiShapeif they should acceptfields/include; included relations follow the visibility rules; only public routes without anyone's own data getapi.cache.public; routes to be removed getapi.deprecated(section 5.3).
7. Adding a sign-in verification step (MFA) from a plugin
ℹ️ Available since: gp247/core 3.6.0
Core does not do OTP itself; your plugin adds a second step to API sign-in (v2 customer and admin) through the interface
GP247\Core\Api\Contracts\LoginChallenge:
| Method | When it is called | You return |
|---|---|---|
required($owner, $request) |
Right after the password is correct (on v1 too) | true when this account must pass the step — do not send the code here |
describe($owner) |
Once, when the ticket is issued (v2) | Send the SMS/e-mail code here if needed; return public hints: method, meta — no secrets |
verify($owner, $request) |
When the app calls POST login/challenge |
true when the answer (fields you define, e.g. code) is correct |
<?php
namespace App\GP247\Plugins\MyOtp;
use GP247\Core\Api\Contracts\LoginChallenge;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
class OtpChallenge implements LoginChallenge
{
public function required(Authenticatable $owner, Request $request): bool
{
return MyOtpSettings::enabledFor($owner); // e.g. the user turned 2FA on
}
public function describe(Authenticatable $owner): array
{
return ['method' => 'totp', 'meta' => ['digits' => 6]]; // public hints only
}
public function verify(Authenticatable $owner, Request $request): bool
{
return MyOtp::check($owner, (string) $request->input('code'));
}
}
// In the plugin's service provider (boot)
config(['gp247-config.api.login_challenge.customer' => array_merge(
config('gp247-config.api.login_challenge.customer', []), [OtpChallenge::class]
)]);
Core does the rest: a single-use ticket (5 minutes, stored hashed in the default cache — works on shared hosting), the attempt
limit (shared with the login counter; a ticket is destroyed after 5 wrong answers, from any IP), and the token/session once verify() returns true. A plugin that throws, or a wrong
class registration, denies the sign-in (the step is never skipped). For administrators register under
gp247-config.api.login_challenge.admin. How an app calls it: API introduction — section 9.4.
8. Order-handling plugins: app orders go through the same hooks
ℹ️ Available since: gp247/shop 3.5.0
An order placed through the API (POST api/v2/front/checkout/place) runs the website's own checkout code. A plugin that already handles
website orders — splitting orders by vendor, booking commissions, using up promotion quotas, sending notifications… — receives app orders
without writing anything for the API:
| Hook | When it runs | Notes |
|---|---|---|
ShopOrder::created model event |
When the order is written | The order lines are written after it, in the same transaction — read them in DB::afterCommit() |
gp247_listen_order_created($order) |
Right after the order is created | Helper function; a site can define its own |
endApp() in the AppConfig of the payment / shipping / total plugin |
When the order is completed | The order id is read from session('orderID'), as on the website |
gp247_listen_order_success($order) |
When the order is completed | API orders: runs as soon as the order is placed, even when it is paid through a gateway (no money yet) |
gp247_order_process_after_success($orderId) |
After the success event | Sends the order confirmation e-mail |
To replace a helper function in the table, add its name to config/gp247_functions_except.php and define the function yourself (same
signature).
// In the plugin's service provider (boot)
\GP247\Shop\Models\ShopOrder::created(function ($order) {
\Illuminate\Support\Facades\DB::afterCommit(function () use ($order) {
// The order lines exist here, whether the order came from the website or the app.
MyCommission::book($order->fresh('details'));
});
});
Three things to keep in mind when a plugin runs inside an API request:
- Order source: the order's
channelcolumn isapifor app orders andwebfor website orders (orders created in the admin and older orders leave it empty). Use it only when you really need to tell them apart — most plugins do not. - No browser session: inside an API request,
session()is a session of that request only (no cookie). Reading / writing the checkout keys works as on the website, but do not redirect, flash messages or read cookies — the app never sees them. - "Order success" does not mean "paid": for API orders the event runs as soon as the order is placed. Work that must wait for the money (digital delivery, unlocking a service…) should rely on the order's payment status.
How the app calls it: API Introduction — section 10.
Q&A
Q1: Where should I add my API — vendor or a plugin?
→ Almost always a plugin (app/GP247/Plugins/) or an override under app/GP247/. Only edit
vendor/gp247/* directly if you are the primary maintainer of that package, because any change inside
vendor is lost on composer update.
Q2: Why doesn't my new route show up?
→ Three common causes: (1) GP247_API_MODE=true is not set in .env; (2) the cache wasn't cleared — run
php artisan route:clear; (3) for a plugin, the file must be named exactly Route.php and the plugin
must be active.
Q3: My plugin's API doesn't return JSON / isn't rate-limited?
→ Because plugin routes are loaded outside the API middleware group. You must wrap them yourself with
'middleware' => GP247_API_MIDDLEWARE as in section 4. This is the single most common mistake when
adding an API from a plugin.
Q4: How is ApiQuery::page() different from Laravel's paginate()?
→ ApiQuery::page() uses paginate() underneath but adds the API v2 standard: it accepts only the filter[...] /
sort names you declare (an unknown name returns 422), caps page[size] at 100, always adds an order by the
primary key so pages neither repeat nor skip records, and returns exactly the {data, meta, links} shape. Use it for
list endpoints to stay consistent with GP247's existing v2 APIs.
Q5: How do I return errors the standard way?
→ Use ApiErrorResponder (notFound(), forbidden(), respond($status, $code, $msg)) — an
{error, msg, code} error body with a meaningful HTTP status code. GP247 converts authentication,
validation and rate-limit errors to this shape automatically. Never return raw HTML or a plain string.
Q6: Do I need a custom scope/ability for my API?
→ Scopes only distinguish account types (admin, admin-supper, user). Permission to view data on an
admin endpoint comes from the admin screen permissions via api.admin:<admin route name> (section 5.1) —
administrators grant a permission once for both the admin panel and the API. Avoid inventing a parallel
permission mechanism.
Q7: Is the apiconnection/apikey layer mandatory for my new API?
→ It's not something you decide in code — it is toggled globally via the api_connection_required
configuration in admin. As long as your route is inside the GP247_API_MIDDLEWARE group, this layer
applies automatically when the admin turns it on.
Q8: Do I have to write my own login/token-issuing endpoint?
→ No. Reuse api/v2/core/login (admin) or api/v2/front/login (customer) to obtain a token, then protect your
new endpoint with auth:admin-api/auth:customer-api. Only write your own login for a special
authentication flow.
Q9: Which class should my API controller extend?
→ For admin data → extend GP247\Core\Controllers\RootAdminController; for front/storefront data
→ extend GP247\Front\Controllers\RootFrontController. They set up the right context for each side.
Q10: Do I need to write tests for the new API?
→ You should — write a feature test (PHPUnit) for the endpoint, in the test folder of your project or plugin. At minimum check: the status code, the JSON structure, and that access is blocked when a private endpoint has no token.
Change history
| Date | GP247 version | Change |
|---|---|---|
| 2026-11-02 | gp247/core 3.8.0 | v2 endpoints get ETag / 304 for free; plugins declare an ApiShape to accept fields / include, add api.cache.public to public routes and api.deprecated to routes being removed (sections 5.3, 6) |
| 2026-11-01 | gp247/core 3.7.0, gp247/shop 3.6.0 | Admin write endpoints: api.admin:<route>,write (permission as a write action on the admin screen), throttle:gp247-api-write, api.idempotent / api.idempotent:optional; sync-ready lists with ApiQuery::page(..., changedSince: true); plugins register event types through gp247-config.api.events and record them with ApiEvents::record(); save through the shop package's models to get the shop's events (sections 5.2, 6) |
| 2026-10-31 | gp247/core 3.6.0, gp247/shop 3.5.0; API v2: core/front/shop 3.3.0 | New endpoints go under the v2 prefixes (GP247_API_V2_CORE_PREFIX, GP247_API_V2_FRONT_PREFIX) and use ApiQuery for {data, meta, links} responses; v1 is frozen (sections 1–6); json.response belongs to GP247_API_MIDDLEWARE and runs before auth:* — a missing Accept no longer gives 500 (section 1); orders placed through API v2 go through the same hooks as website orders (ShopOrder::created, endApp(), gp247_listen_order_success, confirmation e-mail); order channel column (api / web) (section 8) |
| 2026-10-28 | gp247/core 3.6.0 | Plugins add an API sign-in verification step (MFA) through LoginChallenge (section 7) |
| 2026-10-26 | gp247/core 3.2.3; route registration seam: 3.3.0 | Every package/plugin registers its API routes through ApiRouteRegistrar, added the Routes/Api/v2/ folder, ApiAbilities::admin() (3.3.0); admin endpoints must declare their admin screen (api.admin) and filter by store (ApiStoreScope); errors are returned through ApiErrorResponder with meaningful HTTP status codes; public data fields are filtered with ApiFieldAllowlist |
📅 Last updated: 2026-11-02 · ✍️ Author: GP247