Skip to content
Pim Feltkamp edited this page Apr 25, 2026 · 1 revision

FAQ

Questions that come up enough to deserve a permanent answer.

For "how do I do X" see the cookbook. For "why does this error happen" see Troubleshooting.


Why does refine accept attachments but create doesn't?

The backend supports attachments on both, but the SDK currently exposes the field only on projects()->refine, not projects()->create. If you need to anchor a brand-new project against images, two-step it:

$created = $client->projects()->create(
    prompt: $prompt, subdomain: $subdomain,
);
$attachment = $client->uploads()->createFromPath('./mockup.png');

$client->projects()->refine(
    $created['project']['id'],
    message:     'Use this mockup as the visual reference.',
    attachments: [$attachment],
);

This is a known asymmetry — surfacing it on create is on the list but hasn't shipped. Tracked behind the larger "first external customer feedback" gate.


Why are there both stream and waitForLive? Don't they do the same thing?

They wrap the same backend poll, but expose it for different intents:

  • stream($ref, $handler) — calls your $handler with every de-duplicated status snapshot. Use it when you want to show progress (CLI spinner, web UI status line). Throw an exception from the handler to stop polling early.
  • waitForLive($ref) — block-and-resolve. Calls stream internally with a no-op handler, then fetches the project. Use it when you only care about the outcome.

waitForLive throws FloopFloop\Error with code === 'BUILD_FAILED' / 'BUILD_CANCELLED' for non-success terminals, so callers don't need to inspect each event manually.


Why are resources methods ($client->projects()) instead of properties ($client->projects)?

PHP doesn't memoise property access the way modern languages do — every property read hits the property, every method call hits the method. The SDK uses memoised method accessors so $client->projects() returns the same Projects instance on every call without having to declare eight public readonly Projects $projects fields and instantiate them all in the constructor.

Cost: an extra () everywhere. Benefit: fewer class allocations in apps that only use one or two resources, plus the door's open to lazy-construct heavier resources later (e.g. a streaming-only resource that pre-warms a connection pool).


Why is $err->code a string when Exception::$code is protected int?

PHP's stdlib Exception ships with protected int $code (the system errno-style code), but FloopFloop wants $err->code to be a string matching the cross-SDK taxonomy ("RATE_LIMITED", etc.). Naive overrides with public readonly string $code fail with "Cannot redeclare non-readonly property Exception::$code as readonly".

The SDK works around this by:

  1. Assigning the string to the inherited $code slot (PHP allows that at the value level even though the parent type-hint is int).
  2. Exposing it through a __get('code') magic accessor that reads $this->code from inside the class.
  3. Annotating with @property-read string $code so IDEs autocomplete it.

Side effect: $err->code works as expected, but if you look at the Exception parent's view it still thinks the field is an int. PHPStan / Psalm see the @property-read and don't complain. This is documented in the code with a comment pointing back to here.


What's botType and what should I pass?

Optional. Five accepted values, hinting at what the prompt is asking for:

botType Use for
"site" Static / marketing sites, portfolios, landing pages
"app" Interactive web apps with user accounts, forms, persistence
"bot" Telegram / Discord / web bots that respond to messages
"api" Headless API servers (no UI)
"internal" Admin dashboards, internal tools

If you omit it, the backend infers from the prompt. Pass it explicitly when you know — the inference is good but not perfect, and an explicit hint saves a re-roll.

The SDK's create() method takes a positional ?string $botType = null argument or you can pass it as a named argument: botType: 'site'. Either way, the SDK serialises it directly to the wire field botType.


What's the relationship between this SDK and @floopfloop/ai?

Different audiences, different keys, different concerns:

floopfloop/sdk (this package) @floopfloop/ai
Who uses it Anyone — CI scripts, PHP services, integrations Code running inside a deployed FloopFloop project
Auth key User-level flp_* API key Project-scoped flp_sk_* key (auto-injected as FLOOPFLOOP_AI_KEY)
What it does Manage projects (create, refine, list, deploy) Call the LLM gateway (chat, generate)
Distribution Packagist, public Pre-installed in every generated FloopFloop project (Node) — no PHP binding today

@floopfloop/ai is currently a Node-only package. If you're writing PHP on your own infra, use this SDK. If your generated FloopFloop project happens to be a PHP app, the AI Gateway is reachable as POST /api/v1/ai/chat — see the API reference for the wire format.


Why does projects()->get($ref) filter list() instead of hitting a single endpoint?

The backend doesn't expose GET /api/v1/projects/:id today. The SDK papers over that with a list-then-find. When the backend grows the dedicated route, the SDK switches transparently and the public method signature doesn't change.

For accounts with many projects this is a real cost — the workaround is to cache the project handle the first time and reuse it instead of re-resolving.


How do I cancel an in-flight call or waitForLive?

PHP doesn't have a first-class cancellation primitive (no AbortSignal, no context.Context). The closest patterns:

Caller-side wall-clock cap: wrap the call in set_time_limit(N). The SDK's polling loop honours usleep (which is interruptible by signals on Unix), so PHP will eventually unwind out of the loop and into your script's error handler when the time limit hits.

SDK-side cap: pass maxWait: (in seconds) to stream / waitForLive. When it elapses, the SDK throws FloopFloop\Error('TIMEOUT', ...) and unwinds cleanly — much friendlier than set_time_limit.

Use the SDK-side cap unless you're inside a long-running CLI script where set_time_limit already governs the whole process.


Why does refine return a raw array instead of a typed object?

The PHP SDK returns array<string, mixed> from every method today. Keys come straight from the wire (camelCase: messageId, deploymentId, queuePriority).

A typed object (DTO / readonly class) is on the roadmap but holding on first external feedback — typed wrappers are cleaner for some users and a $arr['key'] rough edge for others. The wire-shaped array matches what the other dynamically-typed SDKs (Python, Ruby) do today.


Should I create one Client and reuse it, or make a fresh one per request?

Reuse. The PHP SDK's default StreamClient opens a fresh socket per request (PHP doesn't have HTTP-keep-alive natively for file_get_contents-style transports), but the Client instance still memoises resource accessors and the user-agent / base URL config. Stash one in your container or pass it through DI.

If you want true connection pooling, inject your own HttpClient (e.g. wrapping Symfony\HttpClient or Guzzle) — the SDK accepts any class that implements the HttpClient interface. See the cookbook for the test-double pattern that uses the same hook.


What happens if my API key gets revoked while a stream is running?

The next poll throws FloopFloop\Error('UNAUTHORIZED', ..., 401). The SDK doesn't try to re-auth automatically — auth refresh is an application-level concern.

If you're rotating keys from CI, hold off until the foreground stream completes (cookbook recipe 5 walks through the bootstrap-key pattern that avoids self-revoke).


Why is $err->retryAfter in seconds when other SDKs use milliseconds?

PHP idiom. time() + (int) $err->retryAfter should give you the moment-to-retry directly without unit conversion, and usleep((int) ($err->retryAfter * 1_000_000)) reads naturally. Forcing milliseconds would have every PHP caller do / 1000 before passing to anything time-related — a small papercut multiplied across every retry helper.

Matches the Ruby SDK's convention. Node and Python expose retryAfterMs / retry_after_ms to match their host idioms.


Does the SDK retry failed requests automatically?

No, by design. SDKs that retry-by-default produce silent duplicate side effects ("Why did my project get created three times?"). The cookbook (recipe 6) shows the right shape: opt-in withRetry helper that respects $err->retryAfter and only retries on the codes you actually want.

The one exception is projects()->stream and projects()->waitForLive, which by definition do retry the status poll — that's the point.


How do I run integration tests against a real account without burning credits?

A few patterns:

  1. Use subdomains()->check / usage()->summary — both are free read-only calls and cover most "is the SDK reachable" smoke testing.
  2. Cancel projects immediately after creation — $client->projects()->cancel($id) stops the build before significant credit spend.
  3. Inject a fake transport — the SDK's own test suite does this via the HttpClient interface. Pass httpClient: new FakeHttpClient() to the Client constructor, then queue canned responses with $fake->enqueue(200, $body, $headers). See tests/FakeHttpClient.php for the canonical helper.

For local development without an API key, the third pattern is the cleanest — no network, no credits, fully reproducible. It's the same shape as Guzzle's Mock handler but without taking the dependency.


Why does the SDK use the stdlib stream layer instead of curl?

So zero composer require runtime dependencies. composer.json only requires ext-json, which ships in every PHP build. ext-curl would otherwise be the obvious choice (every Laravel and Symfony app has it), but a non-trivial slice of PHP environments — sandboxes, hosted runtimes, and locked-down enterprise images — don't ship it. The stdlib file_get_contents + stream_context_create path uses ext-openssl for HTTPS but no other extras.

If you want curl (e.g. for HTTP/2 or fancier connection-pooling), inject your own HttpClient — the SDK doesn't care which transport you use.


Got a question I haven't answered?

Open an issue. The bar for adding to this FAQ is "someone else might ask the same thing" — once is plenty.