lihi/lihi-php

Official PHP SDK for the Lihi URL shortener and SMS APIs.

Maintainers

Package info

github.com/lihi-io/lihi-php

pkg:composer/lihi/lihi-php

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0-alpha 2026-08-06 03:55 UTC

This package is auto-updated.

Last update: 2026-08-06 04:05:19 UTC


README

Latest version PHP version License

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() vs getTotalClick() 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 with getTemplate($id). Do not resend.
  • create() timed out? Check with getByLongUrl().
  • 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.