Login
The SDK's apps run for signed-in people, and yet the SDK ships no users table, no login screen and no password reset. That is on purpose, and the line it draws is worth stating plainly:
The SDK owns the login record. Your host owns the login screen.
An administrator in the Partners console creates a login for a partner, locks it, resets its password, removes it. None of that is signing in — it is administering an account. Signing in happens on your own login page, with your own guard, against your own auth store.
The port
interface LoginProviderInterface
{
public function forPartner(int $partnerId): ?LoginRecord;
public function attachedPartnerIds(): array; // int[]
public function create(int $partnerId, string $email, string $password): LoginRecord;
public function setLocked(int $partnerId, bool $locked): void;
public function resetPassword(int $partnerId, string $password): void;
public function delete(int $partnerId): void;
}Every method speaks LoginRecord — the SDK's own DTO — so nothing above the seam ever names your User model:
final readonly class LoginRecord
{
public function __construct(
public int $partnerId,
public string $email,
public bool $isLocked = false,
public ?Carbon $lastLogin = null,
public int $loginCounter = 0,
public bool $mustChangePassword = false,
) {}
public function hasEverLoggedIn(): bool; // loginCounter > 0
}This is the mirror image of connecting your user model. That seam maps auth()->user() → Partner; this one maps Partner → login. Together they are the whole bridge between your identity store and the SDK's actor.
The default: you probably need nothing
The SDK binds EloquentLoginProvider with bindIf, so a stock Laravel app gets working login management with no wiring. It resolves your auth model from config('auth.providers.users.model') — it names no concrete class — and reads and writes these columns on that table:
| Column | Written by | Meaning |
|---|---|---|
partner_id | create() | The bridge to sdk_partners.id |
name | create() | The display name, taken from the partner |
email | create() | The sign-in identifier |
password | create(), resetPassword() | Hashed |
is_locked | setLocked() | Administratively disabled |
must_change_password | create(), resetPassword() | Forced change on next sign-in |
login_counter, last_login | create() sets the counter to 0; otherwise read only, see below | The activity read |
Those columns come from the bridge migration you add to your host — the same one that carries partner_id. It is written out in Installation.
partner_id carries a unique index there, and it is load-bearing rather than tidy: a partner holds at most one login, forPartner() returns a single record, and setLocked(), resetPassword() and delete() are all where('partner_id', …) updates. Two users rows pointing at one partner would make a password reset meant for one person reset both.
If your logins live somewhere else — LDAP, an SSO directory, a legacy table — bind your own adapter in register() and the default never loads. If you manage logins entirely outside the SDK, bind the port to a closure that throws NoLoginProviderBoundException, and the console's login buttons fail with a clear domain message instead of writing to the wrong table.
The surface you get
Bound, the seam lights up four places in the shipped Partners app, most of them in the Authorization section of a partner's detail page:
- Four actions, each gated
#[Act(..., SdkRole::LocalAdmin)]: create, lock/unlock, reset password, delete. A partner holds at most one login; a secondcreateis a 422. Partners(34)?$expand=login— a virtual expand, zero or one row. Empty is the "no login yet" state that drives the Create affordance.- The
userAttachedvalue-help scope — a picker that offers only partners that have a login, built fromattachedPartnerIds().
One rule in that lifecycle is a decision, not an implementation detail: deleting a login never deletes the partner. The partner is the durable actor its assignments belong to. What delete does do is delineate the partner's still-running role, group and ability assignments to now — in one transaction with the removal. The grants would be unreachable anyway, and delineating rather than deleting keeps the audit trail while stopping a re-created login from silently inheriting them.
What the SDK does not do
Nothing here enforces itself at sign-in
is_locked, must_change_password, login_counter and last_login are administered by the SDK and enforced by nobody. The SDK never sits in your authentication path, so:
- Locking a login in the Partners console does not prevent that person from signing in.
must_change_passworddoes not redirect anyone to a change-password screen.login_counternever increments andlast_loginis never stamped, so the Authorization section shows "never signed in" for everyone.
laravelui5/auth does not close this either — its login is a plain Auth::attempt. If you rely on locking, your login path must read these columns itself. A Illuminate\Auth\Events\Login listener that bumps the counter and stamps the timestamp, plus a check on is_locked before Auth::attempt succeeds, is the whole of it.
This is the price of the seam, and it is the honest reading of the SDK owns the record, the host owns the screen:
| Part | Owned by | What it covers |
|---|---|---|
| The record | the SDK | writing and reading the four columns through LoginProviderInterface; the Partners console shows them |
| The sign-in | your login path | whether a locked login gets in, where a forced password change lands, what counts as a sign-in |
The record is complete and correct. Acting on it is the sign-in's job, and the SDK does not reach into the sign-in.
Which login package?
The SDK does not require one. laravelui5/sdk has no login dependency, so a fresh install needs two packages:
composer require laravelui5/sdk laravelui5/authlaravelui5/auth gives you the Fiori-styled login form, password reset, and the home / dashboard jump targets. It is the fastest way to a working install, and it is what both reference hosts use — but it is a choice, not a requirement. SSO, a magic link, or your own Breeze-based form all work, so long as something binds a guard the SDK can read through your user model.