Warning
The Ledga PHP SDK is pre-1.0.0. APIs may change without notice while we iterate towards a stable release. Avoid relying on long-term compatibility at this stage.
The official PHP SDK for the Ledga.io API. Ledga provides programmatic double-entry ledgers for finance, gaming, and multi-tenant SaaS applications.
- PHP 8.1 or later
- Composer
- Guzzle HTTP client (installed automatically)
Install the SDK via Composer:
composer require ledga/ledga-php<?phprequire'vendor/autoload.php';
useLedga\Api\LedgaClient;
$ledga = newLedgaClient('your-api-key');
// Create a transaction$transaction = $ledga->transactions->create([
'description' => 'Payment received',
'effective_date' => '2025-01-02',
'entries' => [
['account_code' => '1000', 'type' => 'debit', 'amount' => '100.00'],
['account_code' => '4000', 'type' => 'credit', 'amount' => '100.00'],
],
]);
echo"Transaction created: " . $transaction->id;useLedga\Api\LedgaClient;
// Production (default)$ledga = newLedgaClient('your-api-key');
// Custom base URL (for development or self-hosted)$ledga = newLedgaClient(
apiKey: 'your-api-key',
baseUrl: 'http://localhost:15080',
timeout: 60
);| Parameter | Type | Default | Description |
|---|---|---|---|
apiKey | string | required | Your Ledga API key |
baseUrl | string | https://ledga.io | API base URL |
timeout | int | 30 | Request timeout in seconds |
// List all accounts$accounts = $ledga->accounts->list();
foreach ($accounts->dataas$account) {
echo$account->code . ': ' . $account->name . "\n";
}
// List with filters$assetAccounts = $ledga->accounts->list([
'type' => 'asset',
'active' => true,
]);
// Auto-pagination (iterate through all pages)foreach ($ledga->accounts->all() as$account) {
echo$account->code . ': ' . $account->name . "\n";
}
// Get a single account$account = $ledga->accounts->get('account-uuid');
// Create an account$account = $ledga->accounts->create([
'code' => '1000',
'name' => 'Cash',
'type' => 'asset',
'category' => 'system', // 'system' (internal/GL) or 'customer' (end-user balance)
]);
// Update an account$account = $ledga->accounts->update('account-uuid', [
'name' => 'Cash - Updated',
]);
// Delete an account$ledga->accounts->delete('account-uuid');
// Get account balance$balance = $ledga->accounts->getBalance('account-uuid');
echo"Settled: " . $balance->settled; // confirmed cleared fundsecho"Pending: " . $balance->pending; // not yet spendableecho"Overdue: " . $balance->overdue; // encumbrances past their due dateecho"Future: " . $balance->future; // encumbrances due today or later// Get account by code (instead of UUID)$account = $ledga->accounts->getByCode('1000');
// Get balance by account code$balance = $ledga->accounts->getBalanceByCode('1000');
// Get account entries with rolling balance$entries = $ledga->accounts->getEntries('account-uuid', [
'start_date' => '2025-01-01',
'end_date' => '2025-01-31',
]);
foreach ($entries->dataas$entry) {
echo$entry->amount . ' -> Balance: ' . $entry->balanceAfter . "\n";
}
// Get balance history over time$history = $ledga->accounts->getBalanceHistory('account-uuid', [
'start_date' => '2025-01-01',
'end_date' => '2025-01-31',
]);
echo"Ending balance: " . $history->endingBalance;POST /transactions is asynchronous: create() and createFromCode() return a TransactionAcknowledgement (the server has accepted the request but not yet committed entries). Poll get($ack->id) once you need the durable record.
// Mode 1 — explicit entries$ack = $ledga->transactions->create([
'description' => 'Invoice payment',
'effective_date' => '2025-01-02',
'idempotency_key' => 'inv-001',
'reference' => 'INV-001',
'layer' => 'settled',
'entries' => [
['account_code' => '1000', 'type' => 'debit', 'amount' => '500.00'],
['account_code' => '1200', 'type' => 'credit', 'amount' => '500.00'],
],
]);
echo$ack->id . "" . $ack->status->value; // "<uuid> pending"// Mode 2 — invoke a transaction code template$ack = $ledga->transactions->createFromCode(
'BOOK_TRANSFER',
[
'amount' => '100.00',
'from_account' => '1000',
'to_account' => '4000',
],
[
'description' => 'Customer payment',
'effective_date' => '2025-01-02',
'idempotency_key' => 'cp-001',
],
);
// Once the ack is produced, fetch the full Transaction$transaction = $ledga->transactions->get($ack->id);
// List transactions$transactions = $ledga->transactions->list([
'status' => 'posted',
'start_date' => '2025-01-01',
'end_date' => '2025-01-31',
]);
// Get a transaction$transaction = $ledga->transactions->get('transaction-uuid');
// Reverse a transaction$reversal = $ledga->transactions->reverse('transaction-uuid', [
'reason' => 'Customer refund',
'date' => '2025-01-15',
]);
// Create multiple transactions in a batch (max 100)$response = $ledga->transactions->createBatch([
[
'idempotency_key' => 'tx-001',
'description' => 'Payment 1',
'effective_date' => '2025-01-15',
'entries' => [
['account_code' => '1000', 'type' => 'debit', 'amount' => '100.00'],
['account_code' => '4000', 'type' => 'credit', 'amount' => '100.00'],
],
],
[
'idempotency_key' => 'tx-002',
'description' => 'Payment 2',
'effective_date' => '2025-01-15',
'entries' => [
['account_code' => '1000', 'type' => 'debit', 'amount' => '200.00'],
['account_code' => '4000', 'type' => 'credit', 'amount' => '200.00'],
],
],
]);
// Check batch resultsecho"Accepted: " . $response->accepted . "/" . $response->total . "\n";
if ($response->hasRejections()) {
foreach ($response->getRejected() as$result) {
echo"Failed: " . $result->idempotencyKey . " - " . $result->error . "\n";
}
}Trancodes are reusable transaction templates. Once created, post a transaction against one with transactions->createFromCode().
// Create a parameterised template$code = $ledga->transactionCodes->create([
'code' => 'BOOK_TRANSFER',
'name' => 'Internal book transfer',
'entries_template' => [
'entries' => [
['account' => '{params.from_account}', 'type' => 'debit', 'amount' => '{params.amount}'],
['account' => '{params.to_account}', 'type' => 'credit', 'amount' => '{params.amount}'],
],
],
]);
// Update name / template (PUT is full-replacement)$code = $ledga->transactionCodes->update($code->id, [
'name' => 'Customer Payment v2',
'entries_template' => $code->entriesTemplate,
]);
// Pre-flight params against the template's params_schema before invoking$valid = $ledga->transactionCodes->validateParams($code->id, [
'amount' => '100.00',
'from_account' => '1000',
'to_account' => '4000',
]);
// Retire a trancode — one-way transition, no reactivate route$code = $ledga->transactionCodes->deprecate($code->id);
assert($code->status === Ledga\Api\Enums\TransactionCodeStatus::Deprecated);Trancodes are append-only: code and status are immutable on PUT, and there is no delete route. Use deprecate() to retire one.
// Create a journal$journal = $ledga->journals->create([
'code' => 'SALES',
'name' => 'Sales Journal',
]);
// List journals$journals = $ledga->journals->list();// Create an account set for reporting$set = $ledga->accountSets->create([
'code' => 'OPERATING_EXPENSES',
'name' => 'Operating Expenses',
]);
// List account sets$sets = $ledga->accountSets->list();// Trial balance$trialBalance = $ledga->reports->trialBalance([
'as_of_date' => '2025-01-31',
]);
// Income statement$incomeStatement = $ledga->reports->incomeStatement([
'start_date' => '2025-01-01',
'end_date' => '2025-01-31',
]);The SDK supports both manual and automatic pagination.
$page = $ledga->accounts->list(['limit' => 25]);
foreach ($page->dataas$account) {
// Process account
}
// Check for more pagesif ($page->hasMore()) {
$nextPage = $page->nextPage();
}
// Navigate backwardsif ($page->hasPrevious()) {
$prevPage = $page->previousPage();
}// Iterate through all items across all pagesforeach ($ledga->accounts->all(['type' => 'asset']) as$account) {
// Process account
}
// Collect all items into an array$allAccounts = $ledga->accounts->all()->toArray();The SDK throws specific exceptions for different error scenarios:
useLedga\Api\Exceptions\LedgaAuthenticationException;
useLedga\Api\Exceptions\LedgaAuthorizationException;
useLedga\Api\Exceptions\LedgaNotFoundException;
useLedga\Api\Exceptions\LedgaValidationException;
useLedga\Api\Exceptions\LedgaConflictException;
useLedga\Api\Exceptions\LedgaRateLimitException;
useLedga\Api\Exceptions\LedgaServerException;
useLedga\Api\Exceptions\LedgaException;
try {
$account = $ledga->accounts->create([
'code' => '1000',
'name' => 'Cash',
'type' => 'asset',
]);
} catch (LedgaValidationException$e) {
// Handle validation errors (400/422)foreach ($e->getErrors() as$field => $messages) {
echo"$field: " . implode(', ', $messages) . "\n";
}
} catch (LedgaAuthenticationException$e) {
// Handle authentication errors (401)echo"Invalid API key";
} catch (LedgaAuthorizationException$e) {
// Handle authorization errors (403)echo"Insufficient permissions";
} catch (LedgaNotFoundException$e) {
// Handle not found errors (404)echo"Resource not found";
} catch (LedgaConflictException$e) {
// Handle conflict errors (409)echo"Idempotency key conflict";
} catch (LedgaRateLimitException$e) {
// Handle rate limiting (429)$retryAfter = $e->getRetryAfter();
echo"Rate limited. Retry after $retryAfter seconds";
} catch (LedgaServerException$e) {
// Handle server errors (5xx)echo"Server error: " . $e->getMessage();
} catch (LedgaException$e) {
// Catch-all for any Ledga API errorecho"Error: " . $e->getMessage();
}| Type | Normal Balance | Description |
|---|---|---|
asset | Debit | Resources owned (cash, inventory, receivables) |
liability | Credit | Obligations owed (payables, loans) |
equity | Credit | Owner's stake in the business |
revenue | Credit | Income earned |
expense | Debit | Costs incurred |
| Layer | Description |
|---|---|
settled | Finalized transactions |
pending | Transactions awaiting settlement |
encumbrance | Reserved funds (holds, commitments) |
The wire format is canonical lowercase on both sides. The API normalises any casing on request (Settled, SETTLED, settled all accepted), so passing TransactionLayer::Settled->value directly into a create payload works as expected. Responses always render the canonical lowercase form.
| Status | Description |
|---|---|
pending | Transaction submitted, awaiting processing |
posted | Transaction successfully recorded |
void | Transaction voided |
failed | Transaction failed validation |
reversed | Transaction has been reversed |
- The Ledga API wraps every single-resource response in a
{"success": true, "data": {...}}envelope. The SDK strips this at the boundary; resource DTOs expose flat properties. - Cursor pagination metadata lives at
meta.pagination.{next_cursor, previous_cursor, limit, has_more}. Use thePaginatedResponse->nextCursor,prevCursor,perPage, andhasMore()accessors. Account::$categoryis anAccountCategoryenum. Compare with cases, not strings:$account->category === AccountCategory::System.- Transaction codes are served at
/api/v1/trancodes. Supported methods:list,all,get,create,update,validateParams,deprecate. Trancodes are append-only —codeandstatusare immutable on PUT, anddeprecate()is a one-way transition (no reactivate).TransactionCode::$statusis aTransactionCodeStatusenum (Active,Deprecated). POST /transactionsis asynchronous and returns aTransactionAcknowledgement(id, status, idempotency key, correlation id, message). Both modes — explicit entries (create()) and trancode invocation (createFromCode()) — funnel through this endpoint. Usetransactions->get($ack->id)to fetch the durable transaction once accepted.
# Install dependencies
composer install
# Run tests
composer test# Run static analysis (PHPStan level 7)
composer analyse
# Run code style check (PSR-12)
composer cs-check
# Run all quality checks
composer quality- Documentation: https://ledga.io/docs
- Email: support@ledga.io
This SDK is released under the MIT License. See LICENSE for details.