planetteamspeak/ts3-php-framework
Object-oriented PHP framework for TeamSpeak 3 ServerQuery, offering full server feature access, file transfers, customizable TSViewer interfaces, exception-based error handling, and event-driven scripting. Unit-tested and suitable for scripts or web apps.
Install and confirm requirements. The package requires PHP 8.1–8.3 and a TeamSpeak server ≥ 3.4.0. That PHP ceiling is the first thing to check on a Laravel project — if you're on PHP 8.4 you're outside the tested matrix.
composer require planetteamspeak/ts3-php-framework
Know the two entrypoints. Everything routes through TeamSpeak3::factory($uri). In a Laravel app use the namespaced class; the exception class is not namespaced (see Gotchas).
use PlanetTeamSpeak\TeamSpeak3Framework\TeamSpeak3;
use PlanetTeamSpeak\TeamSpeak3Framework\TeamSpeak3_Exception;
Put the URI in config, not in code. It encodes host, port, credentials, selected virtual server, and behaviour flags all in one string:
// config/teamspeak.php
return [
'uri' => sprintf(
'serverquery://%s:%s@%s:10011/?server_port=%d',
rawurlencode(env('TS3_QUERY_USER', 'serveradmin')),
rawurlencode(env('TS3_QUERY_PASS')),
env('TS3_HOST', '127.0.0.1'),
env('TS3_VOICE_PORT', 9987),
),
];
Note 10011 is the ServerQuery port; 9987 is the voice port used only to select which virtual server instance you are addressing. Mixing these up is the single most common first-day failure.
First use case — read-only introspection. This is the safest thing to ship first, because it proves connectivity, credentials, and virtual-server selection in one call:
$vs = TeamSpeak3::factory(config('teamspeak.uri'));
dump($vs->getProperty('virtualserver_name'));
dump($vs->clientList()); // online clients
dump($vs->channelList()); // channel tree
Where to look first. The README's "Getting Started" is the real documentation — connection URI options, IPv6 bracket syntax, SSH (?ssh=1), TLS (?tls=1, TeaSpeak only), and URI escaping. After that, the Doxygen class reference for TeamSpeak3_Node_Server is the API map you'll live in. The repo has no in-repo docs/ beyond README and CHANGELOG, so budget time for reading generated API docs rather than tutorials.
Local development. Stand up a throwaway instance instead of pointing at production:
docker run --name teamspeak_server -p 9987:9987/udp -p 10011:10011 -p 30033:30033 \
-e TS3SERVER_LICENSE=accept teamspeak:latest
Grab the token= line from docker logs teamspeak_server on first boot; that's how you claim serveradmin.
Wrap the connection in a dedicated service. Do not scatter TeamSpeak3::factory() through controllers. One class, one cached connection per request, injected via the container:
namespace App\Services;
use PlanetTeamSpeak\TeamSpeak3Framework\TeamSpeak3;
class TeamSpeak
{
private ?object $server = null;
public function server(): object
{
return $this->server ??= TeamSpeak3::factory(config('teamspeak.uri'));
}
public function disconnect(): void
{
$this->server = null; // framework has no explicit close; drop the ref
}
}
Register it as a singleton in a service provider for the request lifecycle only. The framework holds a persistent socket; rebuilding it per call wastes a full TCP + auth handshake (~tens of ms) and burns ServerQuery flood allowance.
Model the node graph, don't flatten it. The framework is object-oriented around nodes — Host → Server → Channel/Client/ServerGroup. Traverse it; each node caches its own properties, so repeated getProperty() on the same node is cheap:
$vs = app(TeamSpeak::class)->server();
$chan = $vs->channelGetByName('General');
$client = $vs->clientGetByName('alice');
$client->move($chan); // move to channel
$client->poke('Please check Discord'); // idle poke
$vs->serverGetSelected()->message('Maintenance in 10 minutes');
Prefer *GetByName() / *GetById() lookups over iterating channelList() yourself — the framework's internal list handling and sort callbacks are already tuned.
Push all writes onto the queue. Query actions are network I/O with variable latency and hard rate limits. Anything that mutates state belongs in a job:
class BroadcastMessage implements ShouldQueue
{
use InteractsWithQueue, Queueable;
public int $tries = 3;
public int $backoff = 15; // ServerQuery flood protection
public function __construct(public string $text) {}
public function handle(TeamSpeak $ts3): void
{
$ts3->server()->serverGetSelected()->message($this->text);
}
public function failed(\Throwable $e): void
{
report($e); // query errors are transient more often than not
}
}
Set public $timeout generously and keep the retry backoff above the server's query-flood window, otherwise retries compound the ban.
Use blocking=0 for fire-and-forget traffic. Appending &blocking=0 to the URI makes the adapter stop waiting for a response line. Correct for chat/poke broadcasts under load, wrong for anything whose result you inspect — you get no confirmation and errors are silently lost. If you need it selectively, build two URIs: a blocking one for control operations and a non-blocking one for fan-out.
Snapshot for provisioning. serverSnapshotCreate() / serverSnapshotDeploy() is the pattern for "spin up a channel layout for a new team." The deploy call takes a mapping option that returns old→new channel IDs — capture it, because IDs change on restore and any DB rows referencing channels go stale.
$snapshot = $vs->serverSnapshotCreate();
$map = $vs->serverSnapshotDeploy($snapshot, true); // returns id map
Use signal slots for event-driven scripting. The framework has a dynamic signal/slot mechanism on the adapter for reacting to notification lines. In a Laravel context this is only useful inside a long-running worker (queue:work, octane, or a dedicated artisan daemon), never in a web request.
Cache reads aggressively. clientList(), channelList(), and serverGroupList() are full round-trips. On any dashboard page, wrap them:
$clients = Cache::remember('ts3.clients', 30, fn () => $ts3->server()->clientList());
Then invalidate on the write jobs that touch them. A 15–30s TTL is normal — TeamSpeak state is not transactional and near-real-time is sufficient for almost every UI.
Scheduled maintenance tasks are where this package earns its keep: nightly snapshot backups, idle-client kicking, stale-channel pruning, permission audits. Keep them in app/Console/Commands with withoutOverlapping() — two concurrent cron runs against the same query account is a reliable way to trigger a flood ban.
Integration tip — isolate the framework behind your own interface. Because the library returns its own node types with legacy snake_case-free method names (channelGetByName, serverGroupIdentify), fake it at the boundary:
interface TeamSpeakGateway {
public function onlineClients(): array; // plain arrays, not nodes
public function broadcast(string $msg): void;
}
Your tests then never boot the framework, and a future swap (or the framework stalling) is a single adapter rewrite.
The exception class is not namespaced. You use PlanetTeamSpeak\TeamSpeak3Framework\TeamSpeak3; but catch TeamSpeak3_Exception. That inconsistency is real and will bite IDE autocomplete. Catch the base class broadly, then inspect getCode() for the numeric ServerQuery error:
try {
$vs->clientGetByName('alice')->poke('hi');
} catch (TeamSpeak3_Exception $e) {
// 1281 = invalid clientID, 513 = invalid channelID, 0 = ok/other
Log::warning('ts3 query failed', ['code' => $e->getCode(), 'msg' => $e->getMessage()]);
}
Some failures surface as TeamSpeak3_Adapter_ServerQuery_Exception, a subclass — catching the base is sufficient.
Encode URI components, never the whole URI. rawurlencode() the username and password individually. Passwords containing @, :, /, or ? will otherwise split the URI into garbage and you'll get a confusing auth failure rather than a parse error. Do not encode the / and & structural characters.
Flood bans are the number one production incident. ServerQuery bans by IP after too many commands in a short window. Symptoms: sudden "login failed" or timeout on every call from a host that worked minutes ago. Mitigations: singleton connection, queue all writes, generous job backoff, Cache::remember on reads, and a sleep(1) between iterations in any loop that does one query per item. Never loop clientList() and then issue a per-client query — batch what you can.
blocking=0 hides errors. Covered above, but worth repeating: it's a fire-and-forget flag. Combined with try/catch it produces code that looks safe and isn't.
IPv6 needs brackets. [fe80::1]:10011. Filter with FILTER_VALIDATE_IP and build the host portion accordingly — the README gives the exact snippet and it's worth copying verbatim.
PHP version ceiling. Tested on 8.1–8.3. On 8.4 you may get deprecation noise from older internal patterns (E_STRICT-era code, dynamic properties). Pin CI to 8.3 and check composer why-not before assuming it'll install on your stack.
Octane / long-lived workers. The socket is stateful and can die silently between requests. Under Octane, either scope the service to the request or add a liveness check that rebuilds the connection on the first failure. Don't let a stale socket poison a worker.
The licence signals are inconsistent. The README says GPL, the package metadata says MIT. Verify before shipping in a commercial product — resolve it against the LICENSE file in the installed vendor directory, not the README prose.
Small ecosystem = you are the support. ~210 stars, zero dependents, one primary maintainer. There's no Stack Overflow corpus for this. Budget for reading source when behaviour is unclear — the node classes are readable and the method naming is discoverable (channel*, client*, serverGroup*, binding*). Version-pin exactly (:1.2.0 or dev-master consciously, never floating) and treat framework upgrades as a change requiring a regression pass against your own integration tests.
Verify query account permissions early. serveradmin works out of the box but is a blunt instrument. Create a dedicated query login with only the permissions your app needs; missing permissions show up as error code 2568 or silent no-ops depending on the command, which is a miserable debugging session. Confirm with $vs->getProperty('virtualserver_queryclientsonline') and a deliberate low-privilege action before you write feature code against it.
File transfers need the filetransfer port. Icon upload/download uses a separate port (30033 by default) and its own handshake via $vs->fileTransferInitUpload() / getFileTransferFileTransfer(). In Docker or behind a firewall, open 30033/TCP or icon features fail while everything else works — a genuinely confusing split failure.
No connection pooling, no ORM-style lazy loading. Every property fetch on an unfetched node may be a query. Fetch the parent node once, read many properties off it, and keep the node object around rather than re-looking it up by name.
How can I help you explore Laravel packages today?