lihi / lihi-php
Official PHP SDK for the Lihi URL shortener and SMS APIs.
Requires
- php: ^7.4 || ^8.0
- ext-json: *
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.0 || ^2.0
- psr/log: ^1.1 || ^2.0 || ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.13
- guzzlehttp/guzzle: ^7.4
- guzzlehttp/psr7: ^2.4
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^9.5 || ^10.5
- squizlabs/php_codesniffer: ^3.7
Suggests
- guzzlehttp/guzzle: Provides a PSR-18 HTTP client and PSR-17 factories, required unless your project already ships its own.
This package is auto-updated.
Last update: 2026-08-06 04:05:19 UTC
README
The official PHP client for the Lihi short URL and SMS APIs.
Read this in another language: 繁體中文 · 简体中文 · 日本語 · 한국어 · Español · Português · Français · Italiano · Русский
Requirements
- PHP 7.4 or newer
- An HTTP client such as Guzzle — the next section installs one for you. Any other PSR-18 client works too, as long as a PSR-17 factory comes with it.
Installation
If your project does not already ship an HTTP client, install one alongside the SDK:
composer require lihi/lihi-php guzzlehttp/guzzle
If you already have one, the SDK will find it:
composer require lihi/lihi-php
Composer needs permission to run the discovery plugin. Add this to your composer.json if it prompts you:
{
"config": {
"allow-plugins": {
"php-http/discovery": true
}
}
}
If no client can be found you get a MissingHttpClientException telling you exactly what to install.
Getting credentials
Sign in at app.lihi.io and open API (/admin/apiDoc).
| API | Plan required | How to get the credential |
|---|---|---|
| Basic | Starter | Available immediately on the API page |
| SMS | Starter, plus a separate SMS key | Apply on the same page |
| Short URL | Business, and the application must be approved | Apply on the same page and wait for approval |
The SMS credential is separate from your API key. Applying for one does not give you the other.
Quickstart
use Lihi\Lihi; $lihi = Lihi::basic('YOUR_API_KEY'); echo $lihi->shorten('https://example.com')->getShortUrl(); // https://lihi.cc/AbCdE
Choosing a client
- Just shortening links?
Lihi::basic()— one method, available on Starter. - Need click counts, edits, deletes or batches?
Lihi::shortUrl()— requires an approved Business plan. - Sending SMS or one-time passwords?
Lihi::sms()— uses the separate SMS credential.
Short URLs
use Lihi\Lihi; use Lihi\ShortUrl\Requests\BatchCreateRequest; use Lihi\ShortUrl\Requests\CreateShortUrlRequest; use Lihi\ShortUrl\Requests\UpdateShortUrlRequest; $lihi = Lihi::shortUrl('YOUR_API_KEY');
Create
$result = $lihi->create( CreateShortUrlRequest::make('https://example.com') ->domain('your-verified-domain.com') ->slug('spring-sale') ->title('Spring Sale') ->desc('Up to 50% off') ->image('https://example.com/og.png') ->tags(['campaign', 'spring']) ->expiredAt(new DateTimeImmutable('2026-12-31 23:59:59')) ); echo $result->getShortUrl();
Creating returns the new URL only — click counts and timestamps do not exist yet. Call get() if you need the full record.
Read
$record = $lihi->get('https://lihi.cc/AbCdE'); // null if it does not exist $record = $lihi->getOrFail('https://lihi.cc/AbCdE'); // throws NotFoundException instead echo $record->getTotalClick(); echo $record->getRepeatClick(); echo $record->getCreatedAt()->format('Y-m-d H:i:s'); foreach ($record->getLongUrls() as $destination) { echo $destination->getUrl(), ': ', $destination->getClick(), PHP_EOL; } // Every short URL pointing at one destination $records = $lihi->getByLongUrl('https://example.com');
getClick()vsgetTotalClick()getClick()returns the figure exactly as the API reported it, which is not always comparable between records.getTotalClick()sums the destinations itself and is consistent however you fetched the record. Prefer it.
Update
$lihi->update( UpdateShortUrlRequest::make('https://lihi.cc/AbCdE', 'https://example.com/new') ->title('Updated title') );
This replaces the record rather than patching it, which is why both the short URL and its destination are required — even when you only want to change the title.
Delete
$lihi->delete('https://lihi.cc/AbCdE');
Batches
// Query up to 100 at a time. Misses come back as null, keyed by what you asked for. $results = $lihi->batchGet(['https://lihi.cc/one', 'https://lihi.cc/two']); foreach ($results as $requested => $record) { echo $requested, ': ', $record === null ? 'not found' : $record->getTotalClick(), PHP_EOL; } // Create up to 5000 at a time. The batch name must be unique on your account. $batch = $lihi->batchCreate( BatchCreateRequest::make('spring-campaign', 'your-verified-domain.com') ->add('https://example.com/a', ['slug' => 'sale-a']) ->add('https://example.com/b') ); foreach ($batch->getUrls() as $pair) { echo $pair['longUrl'], ' -> ', $pair['shortUrl'], PHP_EOL; } $lihi->batchDelete(['https://lihi.cc/one', 'https://lihi.cc/two']);
SMS and one-time passwords
use Lihi\Lihi; use Lihi\Sms\Requests\BulkSmsRequest; $sms = Lihi::sms('YOUR_SMS_TOKEN');
Links in messages need a registered domain. Taiwanese law requires that any URL in an SMS use a domain registered in advance, and a message carrying an unregistered one is rejected. This applies to short links as well as to your own site, so if you are sending into Taiwan from elsewhere, sign in to the Lihi console and get the carrier allowlist approval through before your first send.
One-time passwords
$sms->sendOtp('0912345678', 'YourBrand'); if ($sms->verifyOtp('0912345678', '1234')) { // verified }
Phone numbers may be written in local or international form — the SDK converts them for you. The brand name is optional but must already be registered on your account. Codes are four digits.
A wrong code returns false; it is an ordinary outcome, not an error. Malformed input still throws.
Bulk sending
$request = BulkSmsRequest::make(['0912345678', '0987654321'], 'Your message') ->reservingTime(new DateTimeImmutable('2026-12-31 23:59:59')); echo $request->estimatePoints(); // check the cost before sending $template = $sms->createBulk($request); $job = $sms->getTemplate($template->getId()); echo $job->getTotalRecords(); echo $job->isPending() ? 'in progress' : 'finished'; $sms->cancelTemplate($template->getId());
Up to 1000 recipients per request. Sending costs points: one per 70 characters per recipient for bulk, one per domestic OTP and five per international one. estimatePoints() is for pre-flight checks — the amount actually billed is decided by the service.
Only scheduled messages can be cancelled, and only while they are more than three minutes away. An immediate send cannot be called back.
Configuration
Every factory takes an options array:
$lihi = Lihi::shortUrl('YOUR_API_KEY', [ 'logger' => $psrLogger, ]);
| Option | Default | Purpose |
|---|---|---|
base_uri |
https://app.lihi.io |
Rarely needs changing |
retry |
true |
Retry safe requests — see below |
max_retries |
2 |
How many times |
retry_unsafe_methods |
false |
⚠️ See the warning below |
default_country_code |
886 |
Used when converting local phone numbers |
timezone |
Asia/Taipei |
The zone the API reads timestamps in |
http_client |
auto-discovered | Inject your own PSR-18 client, or a mock |
logger |
none | PSR-3 logger; credentials are never written to it |
user_agent |
lihi-php/{version} |
Unknown options are rejected rather than ignored, so a typo like timeOut fails loudly instead of silently keeping the default.
Credentials are validated on your first call, not when the client is built.
Error handling
Every failure is a Lihi\Exceptions\LihiException, which extends RuntimeException.
LihiException
├── AuthenticationException credential missing, wrong or unknown
├── PermissionException valid key, but your plan or key type is not allowed here
├── QuotaExceededException out of short URL or batch allowance
├── UpstreamRejectedException the request was rejected; retrying will not help
├── ValidationException bad input, caught locally or by the API
│ └── InsufficientPointsException
├── NotFoundException no such record
├── RateLimitException rate limited or on cooldown
├── ServerException a genuine server-side failure
├── TransportException the request never arrived
│ └── TimeoutException it may or may not have arrived
└── MissingHttpClientException no PSR-18 client installed
use Lihi\Exceptions\InsufficientPointsException; use Lihi\Exceptions\LihiException; use Lihi\Exceptions\PermissionException; use Lihi\Exceptions\RateLimitException; try { $sms->sendOtp('0912345678', 'YourBrand'); } catch (InsufficientPointsException $e) { // top up } catch (RateLimitException $e) { sleep($e->getRetryAfter() ?? 60); } catch (PermissionException $e) { // wrong plan, or wrong key for this endpoint } catch (LihiException $e) { error_log($e->getRequestSummary() . ': ' . $e->getMessage()); }
Each exception carries:
| Method | Returns |
|---|---|
getMessage() |
Always a readable message |
getErrors() |
['field' => ['message', …]] for validation failures |
getErrorCode() |
A stable code such as insufficient_points, slug_taken |
getStatusCode() |
HTTP status, or null for local failures |
getRetryAfter() |
On RateLimitException: seconds to wait, when known |
getRequestSummary() |
POST /api/v1/url — safe to log, never contains credentials |
getResponse() |
The raw PSR-7 response |
Branch on the exception type, never on getStatusCode().
Retries
Only GET requests are retried by default. Writes are never retried automatically, because retrying one can send a second SMS, deduct points twice or create a duplicate short URL. You can opt in with retry_unsafe_methods, but read this first:
createBulk()timed out? Check withgetTemplate($id). Do not resend.create()timed out? Check withgetByLongUrl().batchCreate()failed? Fix the input before retrying — failed attempts still consume your batch allowance.
Limits
| Limit | |
|---|---|
| Batch query | 100 URLs |
| Batch create | 5000 URLs |
| Batch delete | 5000 URLs |
| Bulk SMS recipients | 1000 |
| Tag length | 190 characters |
| OTP resend cooldown | 60 seconds per number |
| OTP send / verify | 100 / 200 per minute |
| All endpoints | 1000 requests per minute |
Anything the SDK can check for you is checked before the request is sent, so you find out immediately instead of a round trip later.
Things worth knowing
An unrecognised domain is not an error. Pass a domain that is not verified on your account and you silently get one of your default domains instead. Check the returned URL if it matters.
slug, title, desc and image only apply on your own verified domains. Without one they are ignored, again with no error — you get a randomly generated path. Compare the returned URL against the slug you asked for to detect this.
Long values are truncated rather than rejected. If a slug, title, description or destination URL matters, check it on the record you get back.
Timestamps are read in Asia/Taipei. Pass a DateTimeInterface and the SDK converts it for you. Pass a string and it is sent exactly as written.
Expiry deletes the record, it does not disable it.
getSuccessCount() and getFailedCount() are not populated yet. Track bulk progress with getStatus() and getTotalRecords().
Testing your integration
Lihi\Testing\MockHttpClient ships in the package. It answers from a queue instead of the network, so your test environment does not need a PSR-18 client installed at all.
use Lihi\Lihi; use Lihi\Testing\MockHttpClient; use Psr\Http\Message\RequestInterface; $mock = new MockHttpClient(); $mock->queueJson(200, ['result' => 'success', 'url' => 'https://lihi.cc/AbCdE']); $lihi = Lihi::basic('test-key', ['http_client' => $mock]); self::assertSame('https://lihi.cc/AbCdE', $lihi->shorten('https://example.com')->getShortUrl()); $mock->assertRequestSent('POST', '/api/v1/shortening', static function (RequestInterface $request): bool { return MockHttpClient::jsonBody($request)['longUrl'] === 'https://example.com'; });
Queue: queueJson(), queueResponse(), queueException(). Assert: assertRequestSent(), assertNothingSent(), assertRequestCount(). Inspect: getRecordedRequests(), getLastRequest(), MockHttpClient::jsonBody(). Assertions throw Lihi\Testing\AssertionFailedException rather than depending on any test framework.
Each factory returns an interface — BasicClientInterface, ShortUrlClientInterface, SmsClientInterface — and every entity has a public fromArray() for building fixtures.
Contributing
composer install
composer check # lint, tests, PHPStan, PSR-12, formatting
The test suite never contacts the network.
Versioning
Semantic Versioning. Before 1.0 the public API may still change; every release says what changed on the releases page.
PHP 7.4 support will be kept for as long as it is practical, and dropping it would be a major version.
Support
- Bugs and feature requests: GitHub issues
- Account, billing and plan questions: Lihi
- Domains: we recommend LihiDomain — buy one there and the short URL service comes with it.
Keep your credentials out of version control — read them from the environment.
License
MIT. See LICENSE.