dystcz/dystore-api
JSON:API layer for the Dystore headless commerce stack on Laravel. Exposes products, carts, orders, customers, and checkout as JSON:API endpoints built on Lunar, letting any storefront, SPA, or mobile app run off a single Laravel backend.
Prerequisites. You need a working Lunar install (the headless e-commerce core) on Laravel 10+/11+. Dystore-API is a thin JSON:API layer on top of Lunar — it does not ship its own product/cart/order tables, so if php artisan lunar:install hasn’t run yet, do that first.
Install.
composer require dystcz/dystore-api
php artisan vendor:publish --tag=dystore-api-config
Then check config/dystore.php (or config/dystore-api.php depending on version) — this is the single most important file. It controls:
/api/v1 by default) and middleware stackinclude/filter/sort pathsRun it and hit it.
php artisan route:list --path=api
curl -H "Accept: application/vnd.api+json" https://your.app/api/v1/products
If that returns a JSON:API document with data, links, meta, you’re wired correctly.
First use case to prove the stack. Do the shallowest end-to-end flow before touching anything custom:
GET /api/v1/products?include=variants,thumbnail — confirm sparse fieldsets + includes work.POST /api/v1/carts — create a guest cart, note the returned cart_id.POST /api/v1/carts/{id}/cart-lines — add a purchasable (variant) with quantity.POST /api/v1/checkout (or /orders depending on version) with a payment/address payload.If that loop works, everything else is composition.
Where to look first in the source. Read in this order: config/ → routes/ → src/JsonApi/ (the server + resource classes) → src/Http/Controllers/. The resource classes are the contract; the controllers are boring glue.
Dystore-API uses laravel-json-api under the hood. Every endpoint follows the JSON:API 1.0 spec: data/included/relationships, filter[...], sort=, page[number], fields[type]=, and include=. Front-end clients should use a JSON:API client (e.g. json-api-client, axios wrappers, or generated SDKs) rather than hand-rolling request code.
GET /api/v1/products
?filter[status]=published
&filter[price][between]=10,50
&include=variants,thumbnail,defaultUrl
&fields[products]=name,slug,price
&sort=-created_at
&page[size]=24
Accept: application/vnd.api+json
Sanctum tokens, standard Laravel. Register/login return a token; put it in Authorization: Bearer …. Guest carts don’t require auth — capture the cart ID from POST /carts and pass it forward. Associate the cart to a customer with PATCH /carts/{id} once the user logs in (or use a cart-merge endpoint if your version ships one).
/products and /product-variants with includes; use fields[] to keep payloads small.cart-lines, cart-line-quantities) returns the full cart document so you can re-render from the response rather than re-fetching.GET /orders filtered by customer scope. Use relationships (order-lines, transactions) via includes for order-history pages.The package is designed for extension, not modification:
// app/JsonApi/V1/Products/ProductResource.php
namespace App\JsonApi\V1\Products;
use Dystcz\DystoreApi\JsonApi\V1\Products\ProductResource as BaseResource;
class ProductResource extends BaseResource
{
public function attributes($request): iterable
{
yield from parent::attributes($request);
yield 'my_custom_flag' => fn () => $this->is_featured;
}
}
Register your override in the config’s resource map (or the server class returned by the config), and the base resource is swapped everywhere — routes, includes, related resources all pick it up.
If you need non-JSON:API behavior (webhooks, PDF invoices, admin bulk ops), don’t hijack the JSON:API server. Add a normal Laravel controller in app/Http/Controllers/Api/ and register routes in routes/api.php. Keep those outside the /api/v1/jsonapi namespace so clients know the difference.
Use Laravel’s HTTP assertions plus JSON:API shape checks. Laravel-json-api ships assertJsonApiDocument style helpers via its testing trait — assert on data.type, data.id, and relationship linkage rather than exact JSON, since the package can add attributes between minor versions.
$this->jsonApi()
->expects('products')
->get('/api/v1/products')
->assertFetchedMany();
If you use Pest, wrap these in a shared api() helper that sets the correct Accept and Content-Type headers automatically — otherwise half your tests will mysteriously 406.
Accept header is load-bearinglaravel-json-api returns 406 Not Acceptable for requests without Accept: application/vnd.api+json, and 415 for write requests without Content-Type: application/vnd.api+json. This is the single most common “it doesn’t work” complaint. Set both defaults in your HTTP client:
axios.defaults.headers.common['Accept'] = 'application/vnd.api+json';
axios.defaults.headers.common['Content-Type'] = 'application/vnd.api+json';
?include=variants.prices only works if the resource class declares variants and the related resource declares prices in getIncludes(). If an include silently disappears, it’s almost always a missing allowedIncludes() / getIncludes() entry on the resource, not a route problem. Grep the base resource classes — don’t guess.
Dystore-API tracks Lunar’s schema and model API. Upgrading Lunar without upgrading Dystore-API (or vice versa) is the fastest way to get Method does not exist and column-not-found errors on otherwise-valid queries. Pin both in composer.json with a constraint that reflects this coupling, and read the Dystore changelog before every Lunar bump.
JSON:API relationship links are cheap, but relationship data is lazy-loaded. A product index with include=variants.prices on 50 products will hammer the DB unless the resource eager-loads. Lunar’s repositories already do some of this, but if you override a resource or query, add ->with([...]) explicitly. Use Laravel Debugbar or DB::listen on the index endpoint before going to production.
fields[products]=name,slug,price cuts payload size by 60–80% on real catalogues. But fields[] applies to both the primary type and included types — you must specify fields[variants] too or you’ll get default (full) variant documents. Front-ends that ignore fields[] are the usual cause of slow first paint.
Changing route.prefix is safe. Adding middleware (e.g. a throttle:api or a tenant resolver) needs to be added to both the JSON:API server middleware and any custom checkout routes you add — they’re separate stacks. Put shared middleware in the config so you don’t forget one.
Attempting to POST /checkout with address + payment + shipping in one payload will fail validation because each step depends on the previous. Read src/Actions/Checkout/ (or the equivalent CheckoutController) before you write front-end checkout code — it tells you exactly which transitions are legal and which fields each step demands. This will save you a day.
Laravel-json-api wraps exceptions into JSON:API errors — the underlying exception message goes into error detail only when APP_DEBUG=true. In production, detail is suppressed; log the exception server-side and correlate by request ID. For local debugging, temporarily set JSONAPI_DEBUG=true (if your version exposes it) or dump $exception->getMessage() in a listener rather than in the resource.
fields() and relationships() are the contract; add attributes here, not in controllers.jsonApi() encoder settings.CartUpdated, OrderPlaced, etc.); hook into those rather than adding listeners inside the package.The GitHub description noting [READ ONLY] means the repo is split or mirrored from a bigger monorepo. Issues and PRs may belong upstream (likely the dystcz/dystore umbrella). Don’t open a PR against a mirror — check the README’s contributing link first. This also means the changelog can lag by a few days behind actual tags; inspect the CHANGELOG.md in the tree as the source of truth.
Lunar’s payment drivers are pluggable. During development, bind a fake or test-mode driver in AppServiceProvider so the checkout endpoint doesn’t reach Stripe/PayPal. The Dystore test suite itself does this — copy the pattern from tests/ in the repo if you’re unsure where to hook it.
How can I help you explore Laravel packages today?