Search by

flairuk / good-till-system

ijeffro

A Laravel client for the Goodtill EPOS API, with automatic token management.

Package info

github.com/FLAIRUK/good-till-system

pkg:composer/flairuk/good-till-system

Statistics

Installs: 22

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

v1.0.1 2026-10-05 10:43 UTC

This package is auto-updated.

Last update: 2026-10-05 11:03:58 UTC


README

Goodtill for Laravel

PHP 8.2+  Laravel 12 or 13  Lint  Tests  Downloads on Packagist  MIT licence  Goodtill API 
 

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 the 200 {"status": false} responses Goodtill sometimes returns. If Goodtill can't be reached, Laravel's ConnectionException is 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.