flairuk / good-till-system
A Laravel client for the Goodtill EPOS API, with automatic token management.
Requires
- php: ^8.2
- illuminate/cache: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0|^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-05 11:03:58 UTC
README
Goodtill for Laravel — A Laravel 12 and 13 client for the Goodtill EPOS API.
- Automatic authentication. It logs in once and caches the JWT. The first request after the token is 11 hours old refreshes it, ahead of Goodtill's 12-hour expiry, and a revoked token (a 401) triggers a fresh login.
- Resource classes. Products, customers, sales, reports, external (web) orders, stock and more, with the endpoint quirks handled for you.
- Real errors. Error responses throw
GoodTillException, including the200 {"status": false}responses Goodtill sometimes returns. If Goodtill can't be reached, Laravel'sConnectionExceptionis thrown. - Safe retries. GET requests are retried once on a connection error or a 5xx response. Writes are not retried on those errors, so a sale is never recorded twice; the only resend is after a 401, which Goodtill rejected unprocessed.
- Multi-outlet. Use
GoodTill::forOutlet($id)to work with any outlet.
📦 Installation · 🚀 Usage · 🔌 Testing your integration · 🔄 Upgrading
📦 Installation
composer require flairuk/good-till-system php artisan goodtill:install
Requires PHP 8.2 or later with Laravel 12, or PHP 8.3 or later with Laravel 13.
goodtill:install publishes config/goodtill.php and adds any of these keys that are missing to .env and .env.example, empty. Fill them in:
GOOD_TILL_SUBDOMAIN=yourstore GOOD_TILL_USERNAME=api-user GOOD_TILL_PASSWORD=secret GOOD_TILL_OUTLET_ID= # optional default outlet
Then check the connection:
php artisan goodtill:status
Goodtill only allows store owner or admin users to use the API. Create a dedicated user for the integration, so that "log out of all sessions" in the back office doesn't break it. You can request a test account from dev@thegoodtill.com.
If you run several servers or queue workers, set GOOD_TILL_CACHE_STORE to a shared cache such as redis or database so they all share one token.
🚀 Usage
use FLAIRUK\GoodTillSystem\Facades\GoodTill;
You can also type-hint FLAIRUK\GoodTillSystem\GoodTill to have it injected.
Each method returns the data from Goodtill's response, or the whole body when there is no data key. delete() returns true.
Products
GoodTill::products()->all(); GoodTill::products()->find($id); GoodTill::products()->create([ 'outlet_id' => $outletId, 'vat_code_id' => $vatRateId, 'product_name' => 'Berliner Pilsner', 'selling_price' => 4.75, ]); GoodTill::products()->update($id, $data); // full replacement: omitted fields are cleared GoodTill::products()->delete($id); GoodTill::products()->duplicate($id); GoodTill::products()->createVariant($parentId, $data); GoodTill::products()->inventory();
brands(), categories(), tags(), suppliers() and customerGroups() support the same all / find / create / update / delete methods. staff() and users() support everything except delete.
Customers
GoodTill::customers()->all(); GoodTill::customers()->find($id); GoodTill::customers()->create(['name' => 'Jane Doe', 'email' => 'jane@example.com']); GoodTill::customers()->update($id, $data); GoodTill::customers()->sales($id); GoodTill::customers()->adjustLoyaltyPoints($id, 50, 'Online order bonus'); GoodTill::customers()->addPrepayment($id, ['payment_amount' => '20.00']);
Sales and reports
// Record a sale that has already been fulfilled elsewhere GoodTill::sales()->create([ 'sales_items' => [['product_id' => $id, 'price' => '4.75', 'quantity' => 2]], 'payments' => [['method' => 'CARD', 'amount' => '9.50']], ]); GoodTill::sales()->find($saleId); GoodTill::sales()->between(now()->startOfDay(), now(), ['timezone' => 'utc']); // Fetches full sale details in pages of 50, lazily foreach (GoodTill::sales()->eachDetailBetween(now()->subWeek(), now()) as $sale) { // ... } GoodTill::reports()->salesSummary(now()->startOfDay(), now()); GoodTill::reports()->productsSummary(now()->startOfMonth(), now());
External orders (web shop → POS)
use FLAIRUK\GoodTillSystem\Resources\ExternalSales; $order = GoodTill::externalSales()->create($data); // appears on the POS for fulfilment GoodTill::externalSales()->all(); // outstanding and recently completed orders GoodTill::externalSales()->find($id); GoodTill::externalSales()->updateStatus($id, ExternalSales::READY); GoodTill::externalSales()->updateStatus($id, ExternalSales::REJECTED, ['rejection_reason' => 'Out of stock']); GoodTill::externalSales()->void($id); GoodTill::externalSales()->products();
E-commerce stock sync
GoodTill::ecommerce()->products(); GoodTill::ecommerce()->inventory(skus: ['tshirt-med-green']); GoodTill::ecommerce()->adjustInventory([ ['sku' => 'tshirt-med-green', 'quantity' => 1], // positive decrements stock ]);
Everything else
GoodTill::outlets()->all(); GoodTill::registers()->all(); GoodTill::vatRates()->all(); GoodTill::vatRates()->create(['vat_name' => 'Standard', 'vat_rate' => 20]); GoodTill::paymentTypes()->all(); GoodTill::staffClockRecords()->create(['staff_id' => $id, 'clock_in' => now()->toDateTimeString()]); GoodTill::ingredients()->inventory(); GoodTill::promotions()->all(); GoodTill::vouchers()->create(['code' => 'GIFT50', 'amount' => '50.00']); GoodTill::config(); // store / account settings
For an endpoint without a wrapper, call it directly. Authentication and outlet headers are still handled:
GoodTill::get('some/endpoint', ['query' => 'value']); GoodTill::post('some/endpoint', $data); GoodTill::request(); // a configured Illuminate PendingRequest
Outlets
Requests use the user's current outlet, or GOOD_TILL_OUTLET_ID if it is set. To scope calls to another outlet:
GoodTill::forOutlet($outletId)->reports()->salesSummary($from, $to);
Errors
use FLAIRUK\GoodTillSystem\Exceptions\AuthenticationException; use FLAIRUK\GoodTillSystem\Exceptions\GoodTillException; try { GoodTill::sales()->create($data); } catch (AuthenticationException $e) { // bad credentials, operator account, or missing config } catch (GoodTillException $e) { $e->getMessage(); // includes Goodtill's message, e.g. "The selected payments.1.method is invalid." $e->response?->json(); // the full response } catch (\Illuminate\Http\Client\ConnectionException $e) { // Goodtill could not be reached (DNS, timeout); a GET has already been retried once }
Tokens
GoodTill::tokens()->logout(); // invalidate the token with Goodtill, e.g. when disconnecting an account GoodTill::tokens()->forget(); // drop the cached token locally
🔌 Testing your integration
The client uses Laravel's HTTP client, so Http::fake() works in your own tests:
Http::fake([ 'api.thegoodtill.com/api/login' => Http::response(['token' => 'test']), 'api.thegoodtill.com/api/products' => Http::response(['status' => true, 'data' => []]), ]);
🔄 Upgrading from dev-master
Version 1 is a rewrite. The previous version could not make most API calls (it logged in on every request and several classes were missing), so the API has changed:
| Before | Now |
|---|---|
GoodTillSystem::products()->get() |
GoodTill::products()->all() |
GoodTillSystem::product($id)->get() |
GoodTill::products()->find($id) |
GoodTillSystem::products()->create($data) |
GoodTill::products()->create($data) |
GoodTillSystem::product($id)->update($data) |
GoodTill::products()->update($id, $data) |
GoodTillSystem::product($id)->delete() |
GoodTill::products()->delete($id) |
Facade alias GoodTillSystem |
GoodTill (FLAIRUK\GoodTillSystem\Facades\GoodTill) |
Config goodtill.authorize.*, goodtill.routes.* |
goodtill.subdomain, .username, .password, .base_url |
GOOD_TILL_DOAMIN |
GOOD_TILL_SUBDOMAIN (the old name is still read as a fallback) |
goodtill:setup |
goodtill:install, goodtill:status |
🧪 Testing
composer test
🔒 Security
If you discover a security issue, please email ijeffrouk@gmail.com instead of using the issue tracker.
🙌 Credits
📄 License
MIT. See LICENSE.