ziming/laravel-myinfo-sg
A Laravel package for integrating Singapore's MyInfo API. It streamlines Singpass OAuth authorization, token exchange, and retrieval of verified personal data, letting Laravel applications securely access government-verified user attributes with minimal boilerplate.
Install the package and publish its config:
composer require ziming/laravel-myinfo-sg
php artisan vendor:publish --provider="Ziming\LaravelMyinfoSg\MyInfoServiceProvider" --tag="config"
The first file to read is config/myinfo.php. It contains two credential blocks — singpass for OIDC login and myinfo for person-data retrieval — plus environment switching between staging and production endpoints:
// config/myinfo.php (shape)
'singpass' => [
'client_id' => env('SINGPASS_CLIENT_ID'),
'client_secret' => env('SINGPASS_CLIENT_SECRET'),
'redirect_url' => env('SINGPASS_REDIRECT_URL'),
'environment' => env('SINGPASS_ENVIRONMENT', 'staging'),
'scope' => env('SINGPASS_SCOPE', 'openid'),
],
'myinfo' => [
'client_id' => env('MYINFO_CLIENT_ID'),
'client_secret' => env('MYINFO_CLIENT_SECRET'),
'redirect_url' => env('MYINFO_REDIRECT_URL'),
'environment' => env('MYINFO_ENVIRONMENT', 'staging'),
'purpose' => env('MYINFO_PURPOSE'),
'attributes' => env('MYINFO_ATTRIBUTES'),
],
Register the app in the Singpass/MyInfo developer portal, copy the client ID/secret into .env, and whitelist the exact callback URLs. Staging credentials only work against staging endpoints. Use HTTPS even locally (expose, ngrok, or a local TLS proxy) because the redirect URI must match what is whitelisted.
The first use case is “Log in with Singpass.” Two routes and a controller are enough:
use Illuminate\Support\Str;
use Ziming\LaravelMyinfoSg\Singpass;
Route::get('/login/singpass', function () {
$state = Str::random(40);
$nonce = Str::random(40);
session([
'singpass_state' => $state,
'singpass_nonce' => $nonce,
]);
return redirect()->away(Singpass::getRedirectUrl($state, $nonce));
})->name('singpass.redirect');
Route::get('/auth/singpass/callback', function (Request $request) {
abort_if($request->filled('error'), 403, $request->input('error_description'));
abort_unless(
hash_equals((string) session()->pull('singpass_state'), (string) $request->state),
403
);
$token = Singpass::getToken($request->code);
$userInfo = Singpass::getUserInfo($token['access_token']);
// $userInfo['sub'] is the stable Singpass identifier for this user.
// Persist it, then optionally fetch MyInfo attributes.
})->name('singpass.callback');
The package’s surface is intentionally small: the Singpass class handles the OIDC authorization-code flow, and the MyInfo class handles the MyInfo token exchange and person endpoint. Read those two classes in vendor/ziming/laravel-myinfo-sg/src before building abstractions on top — the whole integration is only a few hundred lines.
Keep Singpass and MyInfo as two separate concerns. Singpass answers “who is this?” and returns sub. MyInfo answers “what data do we have consent to read?” and returns attributes. Store sub on your users table as a unique column (for example singpass_sub), and treat MyInfo as an on-demand profile enrichment rather than your primary identity source.
Exchange the authorization code immediately. Codes are single-use and short-lived. Do the token exchange in the callback and do not stash the code in the session or a queue:
use Ziming\LaravelMyinfoSg\MyInfo;
public function callback(Request $request, UserRepository $users)
{
abort_if($request->filled('error'), 403, $request->input('error_description'));
abort_unless(hash_equals((string) session()->pull('singpass_state'), (string) $request->state), 403);
$token = Singpass::getToken($request->code);
$userInfo = Singpass::getUserInfo($token['access_token']);
$user = $users->firstOrCreateBySingpassSub($userInfo['sub']);
// MyInfo uses its own token exchange and its own callback code.
$myInfoToken = MyInfo::getToken($request->code);
$person = MyInfo::getPersonData($myInfoToken['access_token'], $user->singpass_sub);
return redirect()->intended(route('dashboard'));
}
If your flow uses a single callback for both Singpass and MyInfo, keep the two redirect URIs and two authorization requests distinct; mixing the codes produces confusing token errors.
Normalize MyInfo attributes before storing them. MyInfo returns each attribute as an object with value, source, and updatedAt fields, not a flat scalar. Map only what you need into your own schema:
$profile = [
'name' => data_get($person, 'name.value'),
'dob' => data_get($person, 'dob.value'),
'email' => data_get($person, 'email.value'),
'nationality' => data_get($person, 'nationality.value'),
];
Do not dump the raw MyInfo JSON into a JSON column and call it done; it makes consent, retention, and PDPA obligations harder to reason about.
Protect routes with a session guard. A small middleware that checks for a stored sub/token and redirects to route('singpass.redirect') keeps controllers clean:
public function handle(Request $request, Closure $next)
{
if (! $request->session()->has('singpass_sub')) {
return redirect()->route('singpass.redirect');
}
return $next($request);
}
Treat MyInfo data as point-in-time. Access tokens expire (typically around 30 minutes) and user attributes change. Re-fetch on demand when the user edits a profile or starts a transaction that requires fresh data; cache only with a timestamp and the purpose/consent that justified the fetch.
Drive attributes from config, not from scattered strings. Put the approved attribute list in MYINFO_ATTRIBUTES and pass it explicitly when a screen needs a subset:
$attributes = config('myinfo.attributes'); // comma-separated approved list
$person = MyInfo::getPersonData($myInfoToken['access_token'], $sub, $attributes);
Testing. If the package uses Laravel’s HTTP client (check composer.json and the source), Http::fake() is the cleanest way to test the whole flow without hitting Singpass:
Http::fake([
'*/token' => Http::response(['access_token' => 'test-token', 'token_type' => 'Bearer']),
'*/person/*' => Http::response(['name' => ['value' => 'TAN XIAO HUI']]),
]);
If it uses Guzzle directly, bind a mocked GuzzleHttp\Client in the container instead. Either way, assert that state mismatch, error=access_denied, and token-endpoint failures produce controlled responses.
For Inertia/Livewire apps, keep the callback as a plain redirect endpoint that writes to the session and redirects. Do not try to complete the OAuth exchange inside a component request; the browser navigation semantics of the redirect flow are simpler to reason about.
state and nonce are not optional. Generate both server-side, compare state with hash_equals, and pull() it from the session so it is single-use. If you are not validating the nonce inside the returned ID token, do it yourself against Singpass’s JWKS; otherwise the login is replayable.APP_URL, the session cookie may not be sent and the state check fails. Keep the login and callback on the same domain, or configure the session cookie domain and SameSite settings deliberately.sub values differ. A user logged in on staging has a different sub in production. Never seed or migrate identities across environments.sub. The person endpoint is keyed by the subject identifier from the Singpass ID token, not by the MyInfo access token. Passing the wrong value yields 401/404 responses that look like credential problems but are actually identifier problems.purpose are approved per app. Requesting an attribute your app has not been approved for typically returns a 400/403. Changing the declared purpose may require re-approval. Check the developer portal before adding an attribute to the config.error and error_description on the callback. Users can deny consent, and Singpass will redirect back with error=access_denied. Without this branch, users see a 500 from the token exchange.sub, the purpose, the attribute set, and a timestamp — not the returned payload. Encrypt any cached MyInfo data at rest, set a retention window, and delete it when it is no longer needed.php artisan config:clear locally and php artisan config:cache in deployment. If you override or disable the package’s routes, run route:clear too.composer.json, read the source when behavior is unclear, and be prepared to extend rather than wait for upstream changes. The config file and the Singpass/MyInfo classes are the extension points; if you need custom HTTP behavior, bind your own client or call the endpoints directly with Laravel’s Http facade.http://localhost callback often fails whitelist validation. Use a tunnel and set APP_URL to the tunnel URL before testing the full redirect flow.How can I help you explore Laravel packages today?