Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Myinfo Sg Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • Redirect URI must match exactly. Scheme, host, port, path, and trailing slash all matter. A mismatch produces an error at the Singpass page, not in your Laravel logs. Whitelist both staging and production URLs separately.
  • 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.
  • Session loss on callback. If the callback lands on a different subdomain or a different 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.
  • Staging vs production is a hard boundary. Endpoints, credentials, test personas, and even sub values differ. A user logged in on staging has a different sub in production. Never seed or migrate identities across environments.
  • MyInfo needs the Singpass 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.
  • Attributes and 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.
  • Handle 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.
  • Tokens are short-lived. Do not assume a long-lived access token, and do not put tokens or PII in URLs, logs, or exception messages. Wrap network calls in try/catch and map failures to a friendly “please try again” rather than leaking the upstream response body.
  • PDPA and auditing. Log the 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.
  • Config and route caches. After adding env vars, run 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.
  • Production go-live is more than a config swap. Expect separate credentials, key material, possibly IP allowlisting, and a security review. Staging success does not prove production readiness.
  • The package is small and low-traffic (16 stars). Pin a version in 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.
  • Do not skip HTTPS locally. A plain http://localhost callback often fails whitelist validation. Use a tunnel and set APP_URL to the tunnel URL before testing the full redirect flow.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log