A Laravel package that gives your application a full Telegram client — not just a wire. Updates land in a Postgres relational mirror (3,678 tables, tenant-scoped per account), a Redis bus with hot-reloadable routing fans them anywhere, a multi-account daemon keeps every account ticking, backfill pulls history within quota, and your own data backs up to Telegram channels encrypted.
It sits on top of teleproto, which owns the MTProto 2.0 wire:
your Laravel app ── Eloquent queries, events, artisan commands
│
▼
teleclient ← this package: Postgres truth · Redis bus · daemon · backfill · backup
│ raw TL arrays in, database rows out
▼
teleproto ← the engine: MTProto 2.0 sessions, Bot API, login wizard
│
▼
Telegram
teleproto gives wire power. teleclient gives your app the client.
| Surface | What it does |
|---|---|
| Ingest → Postgres truth | Raw TL payloads become anchor/instance/child rows — idempotent, tenant-scoped |
| 3,116 spatie DTOs + 2,928 Eloquent models | Generated from the official Layer 227 schema |
| Redis bus | One stream per installation, multi-account fan-in, prefix routes with hot reload |
| Multi-account daemon | Round-robin supervisor; FloodWait sleeps, DC migrations and failures are contained |
| Backfill | Quota-sliced history fetch per peer — half the budget always reserved for live traffic |
| Encrypted backups | Your files → Argon2id + XChaCha20-Poly1305 chunks → a private Telegram channel |
use MeRezaRezaei\Teleclient\Teleclient;
$client = app(Teleclient::class);
// Any update payload from teleproto (snake keys, `_` constructor name):
$root = $client->ingest($update, $accountId); // returns the root instance model
// Method responses are route-deduped — identical (method, params, account) hits the stored instance:
$instance = $client->ingestResponse('messages.getHistory', $params, $response, $accountId);Re-ingesting an identical update is a byte-stable no-op. Every ingest requires an account id — there is no global mode, so cross-tenant reads are impossible by construction. After each committed write, Ingest\Events\UpdateStored fires carrying the root model and account id — your listeners take it from there.
// Anchor with its CURRENT instance loaded as `currentInstance` — null if this
// tenant never saw the user, or the user is deleted:
$user = $client->user($accountId, 501558149);
$name = $user?->currentInstance?->first_name;
// The aggregator singleton also resolves chats and channels:
use MeRezaRezaei\Teleclient\Ingest\EntityAggregator;
$entities = app(EntityAggregator::class);
$channel = $entities->channel($accountId, 1049827413);
$chat = $entities->chat($accountId, 7908664201);Or skip the helpers entirely — every TL constructor is an Eloquent model in the MeRezaRezaei\Teleclient\Schema\Generated namespace, and every vector child lands in its own table you can join.
# Consume the stream into Postgres (loop; SIGTERM/SIGINT graceful):
php artisan teleclient:ingest
# Route new messages somewhere else — prefix patterns, first match wins:
redis-cli hset tg:bus:routes 'updateNewMessage*' 'tg:target:messages'
redis-cli publish tg:bus:reload 'reload'Matched entries are forwarded verbatim to the target stream; unmatched entries are ingested; poison entries go to a dead-letter stream and never wedge the group. The consumer re-reads the route table per entry, so routing is hot even without the reload ping.
// config('teleclient.daemon.accounts') — sessions are credentials; keep them in env:
'accounts' => [
['account_id' => 501558149, 'session_string' => env('TELEGRAM_SESSION_501558149')],
['account_id' => 600000001, 'session_string' => env('TELEGRAM_SESSION_600000001'), 'dc' => 4],
],The Daemon class supervises AccountWorkers round-robin (30s slice each), so a wedged account can't starve the rest. FloodWait becomes interruptible sleep, DC migrations rebuild the scope and resume, and repeated failures isolate one account while the others keep ticking. A systemd unit template and the bootstrap wiring are in docs/bus.md.
# /etc/systemd/system/teleclient-daemon.service
[Service]
Type=simple
Environment=TELEPROTO_LIVE=true
ExecStart=/usr/bin/php /var/www/app/artisan teleclient:daemon
Restart=on-failurephp artisan teleclient:backfill --account=501558149 --peer=@channel --budget=25
php artisan teleclient:backfill --account=501558149 --peer=@a --peer=@bThe headroom rule always reserves 50% of the request budget for live traffic; FLOOD_WAIT sleeps capped at an hour consume budget rather than burning the account. Fetched pages land through the same ingest surface.
php artisan teleclient:backup run --set=default --passphrase='...'
php artisan teleclient:backup verify --set=default --sample=5
php artisan teleclient:backup restore --set=default --passphrase='...' --target=/tmp/restoreFiles split into content-addressed 4 MiB chunks, sealed per-chunk with XChaCha20-Poly1305 (key derived from your passphrase via Argon2id13), deduplicated, and uploaded to a private channel the vault provisions itself. The manifest — posted last — alone describes the set: no server-side index, and Telegram only ever sees ciphertext. Details and the full security notes: docs/backup.md.
composer require merezarezaei/teleclientteleproto (the wire engine) is pulled in automatically. Sessions come from teleproto's login wizard:
php artisan teleproto:login # phone / QR / 2FA / bot — writes TELEGRAM_*_SESSIONMigrations ship with the package and load automatically; config merges as teleclient. The offline surfaces (ingest, bus, backup memory driver) need no Telegram credentials at all — only the daemon and live backfill/backup want TELEPROTO_LIVE=true plus TELEGRAM_API_ID / TELEGRAM_API_HASH.
// config/teleclient.php
'daemon' => [ // the shared account registry: daemon, backfill and backup all read it
'accounts' => [ // account_id + session_string (+ optional dc, api_id, api_hash)
['account_id' => 501558149, 'session_string' => env('TELEGRAM_SESSION_501558149')],
],
],
'bus' => [ // Redis bus: stream/group/reload names are fixed by StreamSchema
'connection' => 'default', // your illuminate/redis connection name
'redis_client' => 'predis', // or 'phpredis'
],
'backup' => [
'driver' => env('TELECLIENT_BACKUP_DRIVER', 'memory'), // memory|telegram
'account' => env('TELECLIENT_BACKUP_ACCOUNT'), // daemon.accounts id (telegram driver)
'sets' => [
'default' => [
'paths' => [base_path('docs')], // files/dirs, walked recursively
'excludes' => ['.git', 'node_modules'],
],
],
],- Sync daemon, not an event loop. Round-robin time slices with graceful pcntl stop — simple to run and reason about, not a Fibers/async runtime. One account's FloodWait sleeps inside its own slice.
- The migration dial ships 112 of 635 generated migrations. Only curated namespaces (
auth,messages,users,channels,updates,help,contacts) publish tomigrations/— the full 3,678-table Layer 227 mirror always stays ingenerated/, andphp artisan teleclient:regenerate --shipre-cuts the dial fromteleclient.ship_namespaces. - Update handlers belong to your app. This package emits
UpdateStoredevents and routes stream entries; it deliberately ships no handler framework. The relational mirror is the contract. - Backfill v1 is report-only. It prints per-peer cursors but does not persist them — re-run resumes manually from the reported
offset_id. - Backup passphrase rotation = new set id. Re-keying an existing set produces a mixed-key channel nothing can restore; rotate by running a fresh set. (Full warning in docs/backup.md.)
the teleproto skills directory carries a generated per-surface reference — the same docs your editor's agent reads.
- Quickstart — 4 first recipes
- Ingest reference
- Bus — Redis streams, routing, daemon & backfill
- Backup — encrypted Telegram-channel vault
- Engine layer: teleproto docs
composer test # 301 tests, no Telegram credentials requiredteleclient is open-sourced software licensed under the MIT license.