themattharris/tmhoauth
tmhOAuth is a lightweight PHP library for OAuth 1.0A authentication, commonly used to connect to the Twitter API. It simplifies signed requests, supports GET and POST calls, and provides a straightforward cURL-based interface for building Twitter integrations.
composer require themattharris/tmhoauth
The package is a single-class OAuth 1.0A client with cURL transport. It ships two global classes — TMHOAuth (the client) and TMHUtilities (static helpers) — with no service provider, config file, facade, or contract. In a Laravel app you wire it yourself.
First thing to read is tmhOAuth.php. The constructor is the entire API surface you'll touch 90% of the time:
new TMHOAuth(array $config) // or the legacy positional form
Register a binding once in AppServiceProvider:
use TMHOAuth;
$this->app->singleton('tmh.oauth.app', fn () => new TMHOAuth([
'consumer_key' => config('services.twitter.key'),
'consumer_secret' => config('services.twitter.secret'),
'curl_ssl_verifypeer' => true,
'curl_ssl_verifyhost' => 2,
]));
First use case: the three-legged handshake. Request the temporary token, redirect, then swap the verifier for an access token.
Route::get('/connect/twitter', function () {
$tmh = app('tmh.oauth.app');
$tmh->request('POST', $tmh->url('oauth/request_token', ''));
$token = $tmh->extract_params($tmh->response['response']);
session([
'oauth_token' => $token['oauth_token'],
'oauth_secret' => $token['oauth_token_secret'],
]);
return redirect($tmh->url('oauth/authorize', [
'oauth_token' => $token['oauth_token'],
]));
});
Route::get('/connect/twitter/callback', function () {
$tmh = new TMHOAuth([
'consumer_key' => config('services.twitter.key'),
'consumer_secret' => config('services.twitter.secret'),
'user_token' => session('oauth_token'),
'user_secret' => session('oauth_secret'),
]);
$tmh->request('POST', $tmh->url('oauth/access_token', ''),
['oauth_verifier' => request('oauth_verifier')]);
$creds = $tmh->extract_params($tmh->response['response']);
// persist $creds['oauth_token'] and $creds['oauth_token_secret']
});
After that, every signed call is the same shape: $tmh->request('GET', $tmh->url('statuses/home_timeline', ['count' => 20])).
Per-user client, not one shared instance. Tokens are part of the constructor config, so build a fresh client for each user rather than mutating a singleton:
function twitterFor(User $user): TMHOAuth
{
return new TMHOAuth([
'consumer_key' => config('services.twitter.key'),
'consumer_secret' => config('services.twitter.secret'),
'user_token' => $user->twitter_token,
'user_secret' => $user->twitter_secret,
]);
}
Wrap it in a small gateway. The library returns no DTO and throws nothing, so isolate the response['code'] handling in one place:
class TwitterGateway
{
public function __construct(private TMHOAuth $tmh) {}
public function get(string $path, array $query = []): array
{
$this->tmh->request('GET', $this->tmh->url($path, $query));
if ($this->tmh->response['code'] >= 400) {
throw new TwitterException(
$this->tmh->response['response'],
$this->tmh->response['code']
);
}
return json_decode($this->tmh->response['response'], true) ?? [];
}
public function post(string $path, array $body = []): array { /* ... */ }
}
Queue jobs hold the credentials. Because signing is local and synchronous, this plays nicely inside jobs — load the user, build the client, post, log the HTTP code. Don't store the TMHOAuth instance in a queue payload (it holds secrets and a live cURL handle); store the user and rebuild.
One class, many providers. Override host and use_ssl to point the same client at Tumblr, Flickr, or the old LinkedIn API. Endpoint helpers ($tmh->url()) use that host value, so a generic OAuth1 gateway is viable:
new TMHOAuth(['host' => 'api.flickr.com', 'use_ssl' => true, /* ... */]);
Multipart/upload. Pass multipart: true as the fifth argument to request() for @file style bodies (media upload endpoints); OAuth 1 signing rules change there and the library handles it — do not try to build the payload by hand.
Testing seam. Bind a fake gateway in a test service provider so feature tests never touch Twitter. If you want to test the signing itself, inject a fixed oauth_timestamp/oauth_nonce via config and assert the Authorization header your code produced.
Migration path. Every new integration should sit behind your gateway interface, because league/oauth1-client is the maintained replacement. Keeping the surface to get/post/exchangeToken means swapping later is a one-file change.
composer.json and treat it as frozen. Budget time to move to league/oauth1-client.TMHOAuth isn't autoloadable (older tags shipped an inconsistent autoload map), add a classmap entry in your own composer.json for vendor/themattharris/tmhoauth/tmhOAuth.php and dump-autoload.curl_ssl_verifypeer => true and curl_ssl_verifyhost => 2 explicitly. This is the single biggest production risk with this package.$tmh->response['code'] (HTTP) and $tmh->response['errno'] + ['error'] (cURL). Checking only one of them means transport failures look like empty 200s.user_secret produces a 401, not an error. The signature is computed with an empty key and silently rejected. Validate that both halves of the token pair exist before calling.url()->current() inside a subdomain or with a trailing slash appended later is a classic source of 401 / oauth_problem=signature_invalid.'oob' as the callback to oauth/request_token and collect the verifier from a form instead of redirecting.$tmh->getAuthorizeURL($token, true) (adds force_login), useful when a user needs to switch accounts on a shared browser.oauth_timestamp is rejected if the host's clock drifts by more than a few minutes — usually seen first in containers with broken NTP. Check date inside the container.$tmh->response['headers']['x-rate-limit-remaining'] and back off on 429. The library does not retry.$tmh->getLastApiURL(), which gives the normalized URL including query — safe to log. Never log Authorization headers or user_secret; the signature embeds both consumer and token secrets.setStreaming(true)) is a long-lived blocking cURL loop. It's unusable in a normal FPM request — put it behind an artisan command or dedicated worker.response['response'] vs response['body'] — different versions of the library populate them differently; check both if you're migrating between tags, or normalize inside your gateway.How can I help you explore Laravel packages today?