Search by

lacodix / membergy-sdk-php

lacodix

PHP SDK for the Membergy API. Framework-agnostic with optional Laravel integration.

Package info

github.com/lacodix/membergy-sdk-php

pkg:composer/lacodix/membergy-sdk-php

Statistics

Installs: 12

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.4.0 2026-11-05 06:01 UTC

README

PHP SDK for the Membergy API.

Framework-agnostic: works in any PHP 8.3+ project (Laravel, WordPress, Symfony, plain PHP). Ships with an optional Laravel integration (ServiceProvider, Facade, config publishing) that auto-activates when Laravel is present.

Status

Early development. The versioned CMS v1 slice and the binding S-1 through S-5 website-ready scope are implemented:

  • MembergyClient + MembergyConnector (Saloon-based)
  • content()->pages() (list, find, dynamic includes)
  • content()->posts() (list, category filter, find, dynamic includes)
  • content()->postCategories()
  • content()->menus() with recursive typed targets
  • content()->images() / ->files()
  • content()->events() / ->boilerplates()
  • content()->search() (typed page, post and event groups with continuation)
  • newsletter() (UUID categories, DOI subscribe / headless confirmation / signed unsubscribe request)
  • Typed BlockDocument v1 DTOs, UnknownBlock, UnknownMenuTarget
  • Configurable MenuUrlResolver foundation
  • auth() (credentials and two-factor token, current-token revocation, password reset)
  • me()->access() (tenant access proof independent of person self-service)
  • me()->person() (profile, dynamic form, updates)
  • me()->newsletter() (member-owned category subscriptions)
  • Conditional public/member cache partitions, ETag revalidation and explicit purge
  • Optional recursive Blade renderer, responsive image helpers and Laravel installer
  • API/SDK compatibility matrix, bounded retries, timeouts and rate-limit exception

Requirements

  • PHP 8.3+
  • A Membergy instance with the public content API enabled for your tenant.

Installation

composer require lacodix/membergy-sdk-php

Usage (framework-agnostic)

use Lacodix\MembergySdk\Enums\EventVisibilityType;
use Lacodix\MembergySdk\Enums\SearchResultType;
use Lacodix\MembergySdk\MembergyClient;

$client = new MembergyClient(
    baseUrl: 'https://membergy.app',
    tenant:  'my-club',
    cmsToken: $serverCmsToken, // Load from your server-side secret store.
);

$page = $client->content()->pages()->find('willkommen');

foreach ($page->content->blocks as $block) {
    // Match concrete DTOs such as TitleBlock, TextBlock or ColumnsBlock.
}

$posts = $client->content()->posts()
    ->category('news')
    ->perPage(10)
    ->get();

$post = $client->content()->posts()->find('my-slug', includeDynamic: true);

$hero = $client->image('11111111-1111-4111-8111-111111111111')
    ->width(1600)
    ->height(900)
    ->focus(0.52, 0.38)
    ->format('webp');

$heroUrl = $hero->url();
$heroSrcset = $hero->srcset([480, 768, 1280, 1600]);

$events = $client->content()->events()
    ->eventType('performance')
    ->visibilities(EventVisibilityType::PUBLIC)
    ->perPage(20)
    ->get();

$search = $client->content()->search()
    ->query('Jahresbericht')
    ->get();
$morePosts = $client->content()->search()
    ->query('Jahresbericht')
    ->type(SearchResultType::POST)
    ->page(2)
    ->get();

$categories = $client->newsletter()->categories();
$client->newsletter()->subscribe('guest@example.org', [$categories[0]->uuid]);
$client->newsletter()->confirm($opaqueConfirmationToken);
$client->newsletter()->requestUnsubscribe('guest@example.org');
$client->withoutUserToken()->newsletter()->unsubscribeAll($opaqueUnsubscribeToken);

Posts use offset pagination by default; page(2)->perPage(10) keeps that behavior. For newest-first cursor pagination (publication date, then ID, descending), opt in explicitly, including on the first page:

$posts = $client->content()->posts()->category('news')->cursor()->perPage(10);
$first = $posts->get();

if ($first->hasMorePages()) {
    $next = $posts->cursor($first->nextCursor())->get();
    if ($next->previousCursor() !== null) {
        $previous = $posts->cursor($next->previousCursor())->get();
    }
}

cursor() and cursor(null) select the initial cursor page and clear any previous cursor or page number. Non-null blank cursors are rejected; other cursors are opaque and sent unchanged. Keep the same category and page size when following cursors. Each chained call returns a new resource. Calling page(n) selects offset pagination again and clears cursor settings.

Select multiple categories or exclude categories on the server before pagination:

$posts = $client->content()->posts()
    ->categories('news', 'reports')
    ->excludeCategories('archive')
    ->cursor()
    ->perPage(10);
$first = $posts->get();

categories(string ...$slugs) uses OR inclusion. excludeCategories(string ...$slugs) can accompany either inclusion method; exclusions win overlap. Exclusions alone retain uncategorized posts. Each method requires 1–100 nonblank strings of at most 255 Unicode characters and rejects named argument keys. Values are sent unchanged. Combining category() with categories() throws InvalidArgumentException in either order; empty lists and invalid bounds also fail locally before any request. Filters survive page(), cursor() and cursor(null) chaining without mutating the base resource.

Cursor results contain the same PostSummary items. Use nextCursor(), previousCursor() and hasMorePages() to navigate. meta contains per_page, path, next_cursor and prev_cursor; links contains prev/next URLs and null first/last. Offset totals and page numbers are unavailable in cursor mode: currentPage(), lastPage() and total() return null.

The public subscribe and unsubscribe-request calls return a neutral status and never expose whether the email address or subscription exists. By default, expiring signed links sent by Membergy open its scanner-safe browser landing. A tenant-configured, first-party landing may instead submit the opaque confirmation token from its explicit POST handler to confirm() server-to-server. It may likewise submit an opaque full- unsubscribe token to unsubscribeAll() from an explicit POST handler. The SDK does not decode or cache these tokens, and neither method accepts a callback URL. A GET never changes a subscription. Existing signed category-specific endpoints remain accepted by Membergy for compatibility; new consumers intentionally use unsubscribeAll() for an opaque guest credential and the authenticated me()->newsletter() self-service for category changes.

Membergy has replaced its former forms/register endpoint with dedicated registration offer and application endpoints. The SDK now provides provisional, typed registrationOffers() and registrationApplications() resources against the captured registration-v1 contract. See registration usage and limits. Backend review and an exclusive serial backend test gate are still pending; this contract is not yet integrated or guaranteed for consumers. Early 0.x package releases may include this provisional implementation; they do not establish backend acceptance. See package release policy.

Authenticated per-user access

Public content requires no auth. For members-only content (CMS visibility = members), obtain a versioned user bearer token and switch the client:

$token = $client->auth()->tokenFromCredentials(
    'member@example.org',
    'secret-password',
    'Club website',
    remember: false, // true: absolute 30 days; false: sliding two hours
);
$authed = $client->withUserToken($token->accessToken);

$access = $authed->me()->access()->get();
$internalPost = $authed->content()->posts()->find('internal-only-slug');
$memberEvents = $authed->content()->events()
    ->visibilities(EventVisibilityType::PUBLIC, EventVisibilityType::MEMBERS)
    ->perPage(20)
    ->get();
$profile = $authed->me()->person()->get();
$profileForm = $authed->me()->person()->form();
$updatedProfile = $authed->me()->person()->update([
    'firstname' => 'Alex',
    'metadata' => ['shirt_size' => 'L'],
]);

$subscriptions = $authed->me()->newsletter()->get();
$authed->me()->newsletter()->replace([
    '11111111-1111-4111-8111-111111111111',
]);

$authed->auth()->revokeCurrentToken();

The tenant access proof accepts direct single-/multi-tenant membership or global landlord access and does not require a person record. AccessToken exposes emailVerified, remember and loginEmail; protected endpoints still enforce verification and tenant permissions. Tokens issued by auth-v1 have website scope: anonymous functions, member-visible CMS/search, me() self-service, account-bound registration and self-revocation. Generic administration APIs are refused even for admin/landlord accounts. Pre-deploy unmarked tokens return 401. Normal tokens expire two hours after their last backend use; remembered tokens expire absolutely 30 days after login. For normal tokens expiresAt is the issuance-time idle horizon, not an immutable future deadline. The backend remains authoritative.

Confirmed two-factor authentication throws TwoFactorRequiredException with a random challenge and expiresAt. Store only the challenge on the server until the second request:

// From a caught TwoFactorRequiredException; the challenge expires after five minutes.
$token = $client->auth()->completeTwoFactor($challenge, code: $totpCode);
// Alternatively, exactly one factor:
$token = $client->auth()->completeTwoFactor($challenge, recoveryCode: $recoveryCode);

Five wrong attempts kill a challenge; success is single-use. Recovery codes are replaced and TOTP replay is refused. Wrong and dead challenges expose stable RequestValidationException::errorCode values; 429 maps to RateLimitException::retryAfterSeconds. The SDK clears any user bearer for credential and two-factor issuance. The legacy raw response cannot bypass 2FA.

Own person reads, form and updates require password confirmation newer than three hours. Catch PasswordConfirmationRequiredException, ask for the password on your website, then:

$result = $authed->me()->password()->confirm($password); // AuthResult::status = password_confirmed

Login after the full second factor and successful password changes refresh the token timestamp. Access, newsletter and password change do not require a separate fresh confirmation. Keep interrupted non-secret form values server-side and ask the user to save again afterward. All credential, challenge, code, recovery-code and confirmation parameters are sensitive; Connector-level Saloon request/response hooks stay silent when a user token is configured; request-level hooks also stay silent on bearer-authenticated sends. Sensitive operations suppress both levels even without a bearer. Anonymous non-sensitive debugging remains available, and object debugging redacts token/secret values. Never log request bodies or serialize secret objects.

Password reset reuses Membergy's existing Fortify password policy and notifications:

$client->auth()->requestPasswordResetLink('member@example.org');
$client->auth()->resetPassword(
    $resetToken,
    'member@example.org',
    $newPassword,
    $newPassword,
);

The backend retains its pre-existing raw-string application/json token response for old consumers. The SDK always opts into the structured auth-v1 vendor media type.

Conditional caching and transport resilience

Framework-agnostic consumers can add any PSR-16 stores without changing the resource API:

use Lacodix\MembergySdk\Cache\CacheOptions;

$cached = $client->withCache(
    publicStore: $publicPsr16Store,
    memberStore: $privatePerMemberPsr16Store,
    options: new CacheOptions(
        prefix: 'club-website',
        resourceTtls: ['events' => 60, 'pages' => 300],
    ),
);

$page = $cached->content()->pages()->find('willkommen');
$cached->flush('pages'); // or flush() for every resource generation

Only successful JSON GET/HEAD responses with explicit compatible Cache-Control directives are stored. Fresh entries avoid a request; stale entries revalidate with both If-None-Match and If-Modified-Since, and a 304 restores the cached body. Server no-store always wins. Public and member keys use separate stores/scopes; both partitions also include a fingerprint of the Tenant CMS credential, so credentials cannot share cached content. No raw credential is stored in cache keys or tags. Existing cache entries become cold after this authentication change or a CMS credential rotation. response keys are opaque hashes of the namespace and request identity, never raw URLs or bearer tokens. Laravel leaves member caching disabled by default.

Registration resources bypass SDK caching entirely, including private offers, prefill, token landings and document bytes. Resume credentials are supplied to each offer/submit request explicitly and never stored on the client or shared connector.

GET/HEAD requests retry transient network failures, 429 and 5xx with bounded exponential backoff. Writes are never replayed. Exhausted 429 responses become RateLimitException with an optional retryAfterSeconds value. Registration requests make one attempt and use separate, sanitized RegistrationException types, including RegistrationRateLimitException.

High-level non-registration resources convert Saloon HTTP and transport failures into Lacodix\MembergySdk\Exceptions\MembergyException subtypes. Unmapped HTTP errors, including exhausted 5xx reads and failed writes, throw HttpRequestException with a numeric status and a fixed message. TransportException has a fixed message and no response or HTTP status. These failures and the existing mapped exceptions have no previous exception; they never chain Saloon/Guzzle request or response objects.

Existing authentication, two-factor, self-service, validation and rate-limit exception types remain available. RequestValidationException::errors / errorCode and TwoFactorRequiredException::challenge / expiresAt retain their documented values; validation fields and challenges are redacted in PHP's native var_dump output. Their public typed values can still appear in Symfony VarDumper, Laravel dump / dd, var_export or serializers. Handle them explicitly; never log or serialize them. Only parameters marked #[SensitiveParameter] (including Request objects, profile value arrays and opaque newsletter tokens) are masked in default exception traces.

This replaces the earlier high-level Saloon exception behavior, including ServiceUnavailableException; update consumer catches to the SDK exceptions. Direct $client->connector()->send() is deliberately outside this boundary and keeps raw Saloon exceptions, which can expose request headers, bodies and response data. Do not dump those exceptions. Registration's separate exception boundary is unchanged.

Menus and consumer URLs

Membergy returns stable target type, UUID, slug and title. Route decisions stay in the consuming website:

use Lacodix\MembergySdk\MenuUrlResolver;

$resolver = new MenuUrlResolver([
    'page' => fn ($target) => '/'.$target->slug,
    'post' => fn ($target) => '/news/'.$target->slug,
    'post_category' => fn ($target) => '/news/category/'.$target->slug,
]);

$menu = $client->content()->menus()->find('main');
$url = $resolver->resolve($menu->items[0]->target);

Custom links pass through unchanged. Unknown future block and target types are retained as raw fallbacks so additive API evolution does not break older SDK versions.

The original $client is untouched — withUserToken() returns a new instance, so it's safe to use in Octane / long-lived processes.

Usage (Laravel)

Install config and overridable renderer views:

php artisan membergy:install

Add to .env:

MEMBERGY_URL=https://membergy.app
MEMBERGY_TENANT=my-club
MEMBERGY_CMS_TOKEN=<tenant-cms-credential-from-secret-store>

MEMBERGY_URL defaults to https://membergy.app. Point it at the reachable local or review instance when developing a consumer application, for example MEMBERGY_URL=http://membergy.test.

MEMBERGY_CMS_TOKEN is required for every SDK transport call, including guest login, password, newsletter, contact and registration calls. It is a Tenant-owned credential with fixed cms scope, carried in X-Membergy-Cms-Token; it is independent of the optional visitor Authorization bearer and survives withoutUserToken() and all client copies. Missing or blank configuration throws CmsAuthenticationException locally before network or response-cache access. Existing positional constructor arguments keep their positions; append the named cmsToken argument. fromConnector() uses that connector's credential; supplying another cmsToken alongside an injected connector is rejected. HTTP 401 with top-level code cms_client_unauthenticated also throws CmsAuthenticationException, independently of visitor authentication. Existing visitor 401 codes, including cms_unauthenticated, keep their authentication exceptions; a rejected CMS credential must not expire an otherwise valid visitor session. Set visitor identity through the constructor or immutable user-token copy methods. Replacing Authorization through inherited Saloon authenticators/headers or PSR hooks is rejected, so a request cannot reuse another visitor's private cache partition.

Keep the CMS credential server-side. Never place it in browser configuration, media URLs, HTML or logs. The exact browser binary image/file routes remain key-exempt with their existing visibility/revision checks; JSON metadata calls require the key. URL builders perform no transport and remain usable without a key. No media proxy is introduced.

SDK transport accepts only the configured trusted HTTP(S) origin, with redirects and raw sender diagnostics disabled. SDK debug callbacks are suppressed for all requests while their existing die stop timing is retained. Client serialization is rejected. CMS headers are applied only to the outgoing PSR request and removed from retained response request metadata; reflected credentials are redacted from responses before errors/cache. The SDK uses its own Guzzle sender instead of global Laravel event/telemetry senders; raw debug, on_stats and on_headers sender callbacks are disabled. Do not log low-level transport objects or manually inspect/send credential-bearing outgoing PSR requests.

The current credential-gated backend returns content data/actions private, no-store without validators. The SDK honors that directive. Its conditional cache machinery stays available for compatible responses; a fresh cached entry cannot detect server-side revocation until revalidation/expiry, and authoritative 401/403 always replace stale data.

Then resolve the client from the container or use the facade:

use Lacodix\MembergySdk\Laravel\Facades\Membergy;

$posts = Membergy::content()->posts()->get();

The Laravel binding is request-scoped. A token in the configured session key takes precedence over the optional static token without leaking into Octane or later requests. Config-sourced static tokens remain available for CMS calls; registration offer GET and submit reject them before sending. Use withoutUserToken() for guests or the current applicant's session / explicit withUserToken() client for account registration. Public caching uses the configured Laravel cache store. Useful commands:

php artisan membergy:cache-clear
php artisan membergy:cache-clear pages

Optional Blade renderer

The renderer covers every BlockDocument-v1 block, recursive columns, common settings and dynamic post-category includes. Unknown future blocks are skipped. Views live under the membergy:: namespace and can be published/overridden without forking the SDK:

<x-membergy::content
    :document="$page->content"
    :included="$page->included"
/>

<x-membergy::menu :menu="$menu" :resolve="$menuUrlResolver" />
<x-membergy::events :events="$events->items" />

The SDK provides semantic membergy-* classes but deliberately no fixed theme. Renderer views trust only the backend-sanitized rich-text/raw-HTML contract; unknown raw payloads are never rendered.

Each anchor_navigation v1 block can set data.scope to page (all supported blocks with a nonblank common settings.anchor, except this marker) or following (strictly after this marker until strictly before the next navigation marker, or document end). Omitted scope defaults to page. Every marker ends the preceding following range, irrespective of scope. Ordering is container-first, left-to-right, depth-first through columns, including the columns container itself. Other markers' anchors are eligible in page scope. Original anchor identities and duplicates are preserved; rich-text HTML IDs are not collected. Optional supplied levels remain typed inert metadata; absence does not manufacture levels. Empty ranges render an empty menu. Labels use existing plain heading/title text when nonblank, otherwise the anchor; Blade escapes labels and anchor attributes. No entries/count wire API is introduced. AnchorNavigationBlock::dataToArray() exports scope, supplied levels and additive data fields.

render(document) supplies the full document context. Existing three-argument renderBlocks() / renderBlock() calls remain supported: page menus use the caller-supplied anchors; renderBlocks() derives following ranges from its supplied block list. A standalone renderBlock() cannot infer surrounding document order, so its supplied anchors are treated as the caller's selected range. The supplied renderer carries an immutable navigation context through recursive columns, including older overrides using three arguments. The optional fourth navigation-range map can still select an explicit context. Use render(document) for whole-document selection and own-marker exclusion.

contact_form v1 carries data.form_uuid (UUID string or explicit null). ContactFormBlock retains main's original formHandle/topics constructor positions and legacy hydration, alongside native formUuid, definitionVersion, submitLabel and title. Invalid supplied UUIDs still fail hydration. The default view is an inert UUID hook; applications can fetch native definitions and submit through $client->content()->forms() as described in contact forms. Published application views may render their own native forms.

table v1 exposes title, plain-text headers, rectangular rows, and hasHeader. The optional wire has_header must agree with the headers; older responses derive it. The default view escapes every cell, preserves empty cells/rows, and emits column headers only when present. Tables accept at most 12 columns, 101 logical rows including the header, and 2000 characters per cell.

Page/Post, their summaries and search hits expose hasMemberContinuation (also retained in extra['has_member_continuation'] for existing consumers; false when absent on older responses). The backend supplies guest/member projections and removes editorial member_continuation markers. The SDK never fetches or reconstructs private continuation content. Markers hydrate to an inert DTO and produce neither markup nor navigation links.

Testimonials retain items[].name and expose optional description:string|null, escaped separately from the backend-sanitized rich text. Missing media/file selections in unfinished blocks hydrate as null; views omit missing media and file links. Unselected post categories retain null or the empty string.

The current CMS registry has 17 v1 block types. The fixtures and schema mirrors are synchronized with the current backend public contract; unknown block versions/types and additive fields retain their existing fallback behavior. This source compatibility does not establish a released or deployed consumer integration.

Upgrading an application with published/custom Blade views requires manual reconciliation: files under resources/views/vendor/membergy/blocks/ override the package's vendor/lacodix/membergy-sdk-php/resources/views/blocks/ files, so a Composer upgrade alone leaves old overrides active. Back up/version your overrides, compare each affected file with the new package view, then merge the following changes into your custom markup:

  • anchor-navigation.blade.php: consume the renderer-selected $anchors directly; remove the old levels filter and keep escaped link labels/attributes.
  • columns.blade.php: existing three-argument $renderer->renderBlocks() calls retain whole-document context automatically when invoked through the supplied renderer. Explicit fourth-argument navigation maps remain supported.
  • contact-form.blade.php: use nullable formUuid for native forms. Existing formHandle/topics integrations remain available. Keep browser forms behind the application's own CSRF and server-side native SDK gateway; see the contact guide.
  • image.blade.php, text-image.blade.php, gallery.blade.php, logo-cloud.blade.php, video.blade.php, downloads.blade.php: guard nullable image/video/file references before accessing their UUID or building URLs; retain text independently where applicable. In image/gallery/logo-cloud and agenda.blade.php, guard nullable/empty link URLs before rendering an anchor.
  • testimonials.blade.php: retain name and sanitized quote output, and render the optional description with escaped Blade output only when nonnull.

For an uncustomized override, compare it first and deliberately replace that one file with the matching package view. Publishing with --tag=membergy-views without --force can add missing files but does not upgrade existing overrides. Do not blindly force-republish the directory or overwrite custom views. After merging, clear compiled views with php artisan view:clear in the consumer's normal deployment workflow. Smoke-test page/following menus across columns, empty media selections, Form UUID/null hooks and testimonial escaping in that consumer. These instructions do not claim execution against any published/custom consumer installation.

See the compatibility matrix for the supported API and contract versions.

Testing

MEMBERGY_BACKEND_ROOT=/path/to/cms-source \
MEMBERGY_REGISTRATION_BACKEND_ROOT=/path/to/registration-source \
composer test

Unit tests run locally. Integration tests use Saloon's MockClient to simulate HTTP responses — no running Membergy instance required. Both source checkouts are required for the separate fixture gates.

The fixtures under tests/Fixtures/Contracts/Cms/v1 contain all 64 current backend CMS JSON fixtures (responses and document examples), plus block-registry.json. The gate requires the real MEMBERGY_BACKEND_ROOT checkout and compares complete path sets and every fixture byte. The schema family is mirrored under the existing tests/Fixtures/Contracts/AnchorNavigation/v1 destination and checked independently against backend schema bytes. Local synthetic anchor examples remain distinct from backend HTTP captures. Registration fixtures and drift remain independently gated.

Registration fixtures live separately under tests/Fixtures/Contracts/Registration/v1: 43 captured backend responses (including the exact PDF bytes), their request/status/header metadata, OpenAPI, JSON schema and provenance. MEMBERGY_REGISTRATION_BACKEND_ROOT selects that source checkout. Its independent gate rejects missing or extra SDK files and any byte difference; it never skips or changes the CMS gate's source.

License

MIT — see LICENSE.md.

Own contact rows and bank details

me()->person()->form() exposes repeater fields with typed child fields and max. $form->repeaterRows('contact_details') returns FormRow objects (key, values). The raw keyed values remain available unchanged. PersonProfile exposes typed contactDetails and bankDetails; the authorized owner receives their full own IBAN. Never cache it across users.

use Lacodix\MembergySdk\DataObjects\ContactDetail;

$client->me()->person()->update([
    // Include the other required values from the current form.
    'contact_details' => [
        new ContactDetail('78001', 'private_email', 'member@example.test', 'club'),
        new ContactDetail($clientUuid, 'private_phone', '+513512345678', 'private'),
    ],
    'bank_iban' => 'DE89370400440532013000',
]);

Existing keys are opaque IDs of the owner's rows; new keys must be client-generated UUIDs. Submitting the repeater replaces all rows (missing existing rows are deleted). An empty array submits an empty object and clears contacts; omitting the repeater preserves them. Foreign IDs, unknown row fields and invalid contact data are rejected atomically. Contact emails never change the login email. RequestValidationException::errors retains dotted row paths.

Authenticated password change and management access

$result = $client->me()->password()->update($currentPassword, $password, $confirmation);
// PasswordUpdateResult::status === 'password_updated'
$managementUrl = $client->me()->access()->get()->managementUrl; // string|null

Password parameters carry #[SensitiveParameter]; the password request suppresses both request and connector Saloon debugging and redacts object debug output. Do not log or dump request bodies. Validation maps to RequestValidationException::errors, and throttling to RateLimitException::retryAfterSeconds. All tokens/sessions remain valid after a change; Membergy sends no mail. The new endpoint requires the additive self-service-v1 backend. Only real management rights in the URL tenant yield a management URL. The destination enters Membergy through its own login and permission checks.

One-time management login (package E, 2026-10-09)

$client->me()->managementLogin() returns ManagementLogin with a sensitive url and typed expiresAt. Its URL and payload parameters are SensitiveParameter and its __debugInfo redacts the URL. The request uses self-service-v1; 403/401/429 map to the existing forbidden/auth/rate exceptions. This method needs the additive backend management-login route and ticket migration. 64 CMS fixtures plus the BlockRegistry and the independent registration provenance are synced byte-for-byte. Capture and canonicalization remain backend-owned.

The server enforces a 60-second single-use ticket bound to user, club and issuing website token. Real management rights, token lifetime and confirmed 2FA activation are checked again on redeem. The new web session inherits the token confirmation time, preserving the three-hour password gate. It replaces any existing Membergy session, including impersonation, and has one fixed club management target. Website and Membergy logouts stay independent. Treat the URL as a credential; never print it or enable raw response debugging. Configure upstream access logs to mask ticket paths.