> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stockshift.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Laravel

> Use the StockShift API from a Laravel app. The package is built on the StockShift PHP SDK. It adds config, a facade, queries that return collections, a webhook route that fires Laravel events, queued calls, a test fake and Artisan commands.

It needs PHP 8.2 or later and Laravel 12 or 13.

## Install

```bash theme={null}
composer require stockshift/laravel
php artisan stockshift:install
```

`stockshift:install` publishes `config/stockshift.php`, adds `STOCKSHIFT_TOKEN` and `STOCKSHIFT_WEBHOOK_SECRET` to `.env` and `.env.example` and asks for their values. Create an API key in StockShift under Settings > Access > API keys. Then check it:

```bash theme={null}
php artisan stockshift:test
```

## Quickstart

```php theme={null}
use StockShift\Laravel\Facades\StockShift;

StockShift::catalog()->createItem(['sku' => 'BOLT-M4', 'name' => 'M4 hex bolt', 'type' => 'stocked', 'make_or_buy' => 'buy']);

$low = StockShift::stock()
    ->where('status', 'available')
    ->orderBy('available_to_promise')
    ->lazy();

foreach ($low as $row) {
    echo $row['sku'], ' ', $row['available_to_promise'], "\n";
}
```

## Making calls

The facade has every area of the API, such as `catalog()` or `inventory()`, and each area has a method for each operation. They are the SDK's methods, with the same arguments and PHPStan types:

```php theme={null}
$item = StockShift::catalog()->getItem('01J8QZ4M...');
echo $item['data']['sku'];
```

You can also call a path directly with `get()`, `post()`, `put()`, `patch()`, `delete()` or `request()`:

```php theme={null}
StockShift::get('/catalog/items/{item}', ['path' => ['item' => '01J8QZ4M...']]);
```

The facade, an injected `StockShift\Laravel\Connection` and an injected `StockShift\Sdk\StockShift` all use the default connection.

## Queries

Each list of the API has a query builder. `where()` sends the API's own filters, and the results come back as Laravel collections:

```php theme={null}
$bolts = StockShift::items()
    ->search('bolt')
    ->where('status', 'active')
    ->select('sku', 'name')
    ->get();                  // Collection

$first = StockShift::items()->where('type', 'stocked')->first();
$count = StockShift::purchaseOrders()->where('status', 'sent')->count();
```

`lazy()` returns a `LazyCollection` that fetches the next page only when you reach it. With `limit()` or `take()`, pages past the limit are never fetched:

```php theme={null}
StockShift::movements()->where('item', 'BOLT-M4')->lazy()->take(50)->each(function (array $movement) {
    // ...
});
```

The API returns each list in its own fixed order. `orderBy()` and `orderByDesc()` sort here instead, so they read the whole list first. Fields may use dot notation.

The builders on the facade are `items()`, `stock()`, `movements()`, `counts()`, `transfers()`, `locations()`, `suppliers()`, `purchaseOrders()`, `customers()`, `salesOrders()`, `quotes()`, `invoices()`, `returns()`, `builds()`, `boms()` and `workCenters()`. Any other list works by its path:

```php theme={null}
StockShift::query('/sales/price-lists/{priceList}', ['priceList' => $id])->get();
```

Other methods:

| Method | What it does |
| - | - |
| `when($condition, fn ($query) => ...)` | Adds to the query only when the condition holds. |
| `after($cursor)` | Starts after a cursor you kept. |
| `page($cursor = null)` | One page, with `records` and `nextCursor`, for paging by hand. |
| `remember($ttl = null)` | Keeps the results of `get()`, `first()` and `count()` in the cache. |
| `toQuery()` | The query string the API will get. |

Enums are sent as their value and dates as ISO 8601.

## Connections

A connection is a token, the webhook secret that goes with it and its settings. Add more in `config/stockshift.php` to call several organizations:

```php theme={null}
'connections' => [
    'default' => [/* ... */],
    'eu' => [
        'token' => env('STOCKSHIFT_EU_TOKEN'),
        'webhook_secret' => env('STOCKSHIFT_EU_WEBHOOK_SECRET'),
    ],
],
```

```php theme={null}
StockShift::connection('eu')->items()->get();
```

To inject one, mark the parameter:

```php theme={null}
use StockShift\Laravel\Attributes\StockShiftConnection;
use StockShift\Laravel\Connection;

public function __construct(#[StockShiftConnection('eu')] private Connection $stockshift) {}
```

Each connection has these settings:

| Key | Default | Meaning |
| - | - | - |
| `token` | `STOCKSHIFT_TOKEN` | An API key or OAuth access token. |
| `webhook_secret` | `STOCKSHIFT_WEBHOOK_SECRET` | The signing secret, or several separated by commas while you rotate it. |
| `base_url` | `https://app.stockshift.io/api/v1` | Where the API is. |
| `timeout`, `connect_timeout` | `30`, `10` | Seconds. |
| `max_retries` | `2` | Retries for a call that can be retried. |
| `max_retry_after` | `60` | The longest `Retry-After`, in seconds, worth waiting for. |
| `rate_limit` | `null` | Calls a minute this app makes with one token. |
| `headers` | `[]` | Headers sent on every call. |

### A token for each tenant

In an app where each tenant connects its own StockShift organization, look the token up before each call:

```php theme={null}
// In a service provider's boot method
StockShift::resolveTokenUsing(fn (string $connection) => tenant()->stockshift_token);
```

Or make a connection for one token where you need it:

```php theme={null}
StockShift::withToken($team->stockshift_token)->items()->get();
```

Cached query results and rate limits are kept apart for each token.

## Errors

A failed call throws the SDK's `ApiException`, which holds the RFC 9457 problem details body. Branch on `errorCode`, which is stable:

```php theme={null}
use StockShift\Sdk\Exception\ApiException;

try {
    StockShift::catalog()->createItem($item);
} catch (ApiException $error) {
    if ($error->errorCode === 'validation_failed') {
        return back()->withErrors($error->errors());
    }

    throw $error;
}
```

A call that gets no answer throws a `ConnectionException`. Both extend `StockShiftException`.

## Retries, idempotency keys and queued calls

The SDK retries a `429` after `Retry-After`. It retries a `5xx` or a network failure for `GET`, `PUT` and `DELETE`, and for a `POST` or `PATCH` only when it has an idempotency key:

```php theme={null}
StockShift::integrations()->pushStockNow($connectionId, ['idempotencyKey' => (string) Str::uuid()]);
```

`later()` sends a call from a queued job. A `POST` or `PATCH` gets an idempotency key when the job is made, so the job's own retries send the same key and the API makes the change once:

```php theme={null}
StockShift::later('POST', '/catalog/items', ['body' => $item])->onQueue('stockshift');
```

The job is `StockShift\Laravel\Jobs\SendRequest`. It tries five times. A `4xx` answer other than `429` fails it at once, because sending it again gets the same answer. A token from `withToken()` goes with the job. The job is encrypted on the queue.

## Rate limits

Set `rate_limit` on a connection to cap the calls a minute your app makes with one token. Every process that shares the cache store shares the count. A call that would go over waits until it can be sent. Leave it `null` to rely on the API's limit, which the client already waits out.

Requests go through Laravel's HTTP client, so `Http::fake()`, the `RequestSending` and `ResponseReceived` events and tools such as Telescope see them.

## Webhooks

The package adds a `POST /stockshift/webhook` route. Give its full URL to StockShift when you add a webhook endpoint, and put the signing secret StockShift shows you in `STOCKSHIFT_WEBHOOK_SECRET`.

Each delivery is checked against the `StockShift-Signature` header. One that fails gets a `400` and goes no further. One that passes fires two Laravel events: `WebhookReceived` for every delivery, then the event's own class, such as `InventoryStockMoved`:

```php theme={null}
use Illuminate\Support\Facades\Event;
use StockShift\Laravel\Events\InventoryStockMoved;

Event::listen(function (InventoryStockMoved $event) {
    // $event->id, $event->type, $event->created, $event->data['item_id'], $event->connection
});
```

Every event type in the API reference has a class in `StockShift\Laravel\Events`, with `$data` typed for PHPStan. `WebhookReceived` also fires for types this version of the package has no class for.

### Listening with an attribute

Mark a class or a method with `#[OnStockShiftWebhook]`:

```php theme={null}
namespace App\Listeners;

use StockShift\Laravel\Attributes\OnStockShiftWebhook;
use StockShift\Laravel\Events\InventoryStockMoved;

#[OnStockShiftWebhook('inventory.stock.moved')]
class UpdateShelfLabels
{
    public function handle(InventoryStockMoved $event): void
    {
        // ...
    }
}
```

On a class it calls `handle()` or `__invoke()`. On a method it calls that method. Give several types, or end one with `*` to match many, such as `sales.order.*`. Type the parameter as `WebhookEvent` when it can get more than one class. `*` alone listens to `WebhookReceived`.

Classes in `app/Listeners` are scanned. Change `webhooks.discover` in the config to scan other folders. A type that matches no event fails at boot with a hint, so a typo does not go unnoticed. A listener that Laravel's own event discovery already found by its type hint is not added twice. Listeners that implement `ShouldQueue` are queued as usual.

`php artisan optimize` caches the listeners it finds. `stockshift:cache` and `stockshift:clear` do the same on their own.

### Retries and queueing

StockShift retries a delivery that does not get a `2xx`, and each retry keeps the event's `id`. The package remembers ids for a day and fires the events once. If a listener throws, the id is forgotten and the delivery gets a `500`, so the retry is handled. Change `webhooks.deduplicate_for` to keep ids longer, or set it to `0` to keep none.

Set `STOCKSHIFT_WEBHOOK_QUEUE` to answer StockShift at once and fire the events from a queued job. `STOCKSHIFT_WEBHOOK_QUEUE_CONNECTION` picks the queue connection.

### Other routes

Set `webhooks.path` to `null` to leave the route out and place it yourself. A route for another connection checks that connection's secret:

```php theme={null}
Route::stockshiftWebhooks('webhooks/stockshift-eu', 'eu');
```

The middleware works on its own too, as `stockshift.webhook` or `stockshift.webhook:eu`. It puts the verified event in the `stockshift.event` request attribute. Webhook routes need no CSRF exception because they are not in the `web` group. To add middleware, such as `throttle:120,1`, list it in `webhooks.middleware`.

## Calling the API by hand

`Http::stockshift()` is a Laravel HTTP request with the connection's base URL, token and timeouts. It does not retry or throw on errors:

```php theme={null}
$response = Http::stockshift()->get('catalog/items', ['search' => 'bolt']);
$response->json('data.items');

Http::stockshift('eu')->post('catalog/items', $item);
```

## Artisan commands

| Command | What it does |
| - | - |
| `stockshift:install` | Publishes the config and adds the settings to `.env`. |
| `stockshift:test` | Checks the token and shows who it belongs to. |
| `stockshift:webhooks` | Lists the organization's webhook endpoints. `--events` lists the event types an endpoint can subscribe to. |
| `stockshift:cache`, `stockshift:clear` | Cache or clear the attribute listeners. |

Each takes `--connection` where it calls the API. `php artisan about` shows the default connection and whether listeners are cached.

## Testing

`StockShift::fake()` answers calls from responses you give and records each one. Nothing reaches the API, and no token is needed:

```php theme={null}
use StockShift\Laravel\Facades\StockShift;
use StockShift\Laravel\Testing\RecordedRequest;

$fake = StockShift::fake([
    'GET /catalog/items' => StockShift::page([['sku' => 'BOLT-M4']]),
    'GET /catalog/items/*' => ['data' => ['sku' => 'BOLT-M4']],
    'POST /catalog/items' => StockShift::response(['data' => ['id' => '01J9']], 201),
    'DELETE /catalog/items/*' => 204,
    'POST /purchasing/orders' => StockShift::problem(422, 'validation_failed', 'The supplier is archived.'),
]);

// Run your code, then:
StockShift::assertSent('POST /catalog/items', fn (RecordedRequest $request) => $request->body['sku'] === 'BOLT-M4');
StockShift::assertNotSent('DELETE /catalog/items/*');
StockShift::assertSentCount(3);
```

A key is a method and a path after the base URL. `*` matches anything. A value is a JSON body, a status code, a PSR-7 response or a closure that takes the `RecordedRequest` and returns one of those. `StockShift::page()` builds a page of a list with the records under the name the list uses. A call with no response throws `UnexpectedRequest`. Calling `fake()` again adds responses to the same fake.

`RecordedRequest` has `connection`, `method`, `path`, `query`, `body`, `header()` and `idempotencyKey()`. `recorded()` returns them all, or those matching a route. `assertNothingSent()` checks that no call was made.

To test your webhook listeners, send a signed delivery through the route:

```php theme={null}
StockShift::fakeWebhook('inventory.stock.moved', [
    'item_id' => '01J8QZ4M...',
    'quantity' => '2.000000',
])->send()->assertNoContent();
```

`dispatch()` fires the events without the HTTP request. `payload()` and `signature()` give the body and header if you want to send it yourself.

Because calls go through Laravel's HTTP client, `Http::fake()` works too, if you would rather fake at the HTTP level.

## Versions

The package follows semantic versioning. It supports the Laravel versions that get security fixes and the PHP versions each of them supports. A new major version of the SDK comes with a new major version of the package.


## Related topics

- [Webhooks](https://docs.stockshift.io/guides/webhooks.md)
- [SDKs](https://docs.stockshift.io/sdks/index.md)
- [StockShift for developers](https://docs.stockshift.io/index.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.