Skip to content

Repository files navigation

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.

Ledga PHP SDK

The official PHP SDK for the Ledga.io API. Ledga provides programmatic double-entry ledgers for finance, gaming, and multi-tenant SaaS applications.

Requirements

  • PHP 8.1 or later
  • Composer
  • Guzzle HTTP client (installed automatically)

Installation

Install the SDK via Composer:

composer require ledga/ledga-php

Quick Start

<?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;

Configuration

Basic Configuration

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
);

Available Options

ParameterTypeDefaultDescription
apiKeystringrequiredYour Ledga API key
baseUrlstringhttps://ledga.ioAPI base URL
timeoutint30Request timeout in seconds

Usage

Accounts

// 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;

Transactions

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";
}
}

Transaction Codes (Templates)

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.

Journals

// Create a journal$journal = $ledga->journals->create([
'code' => 'SALES',
'name' => 'Sales Journal',
]);
// List journals$journals = $ledga->journals->list();

Account Sets

// Create an account set for reporting$set = $ledga->accountSets->create([
'code' => 'OPERATING_EXPENSES',
'name' => 'Operating Expenses',
]);
// List account sets$sets = $ledga->accountSets->list();

Reports

// 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',
]);

Pagination

The SDK supports both manual and automatic pagination.

Manual 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();
}

Automatic Pagination

// 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();

Error Handling

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();
}

Domain Concepts

Account Types

TypeNormal BalanceDescription
assetDebitResources owned (cash, inventory, receivables)
liabilityCreditObligations owed (payables, loans)
equityCreditOwner's stake in the business
revenueCreditIncome earned
expenseDebitCosts incurred

Transaction Layers

LayerDescription
settledFinalized transactions
pendingTransactions awaiting settlement
encumbranceReserved 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.

Transaction Status

StatusDescription
pendingTransaction submitted, awaiting processing
postedTransaction successfully recorded
voidTransaction voided
failedTransaction failed validation
reversedTransaction has been reversed

Response shape and contract

  • 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 the PaginatedResponse->nextCursor, prevCursor, perPage, and hasMore() accessors.
  • Account::$category is an AccountCategory enum. 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 — code and status are immutable on PUT, and deprecate() is a one-way transition (no reactivate). TransactionCode::$status is a TransactionCodeStatus enum (Active, Deprecated).
  • POST /transactions is asynchronous and returns a TransactionAcknowledgement (id, status, idempotency key, correlation id, message). Both modes — explicit entries (create()) and trancode invocation (createFromCode()) — funnel through this endpoint. Use transactions->get($ack->id) to fetch the durable transaction once accepted.

Testing

# 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

Support

License

This SDK is released under the MIT License. See LICENSE for details.

About

PHP SDK for Ledga

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages