mosparo/php-api-client
PHP API client for mosparo that verifies form submissions server-side using submit and validation tokens. Install via Composer, then check isSubmittable() before processing data. Compatible with mosparo 1.5+ and older verification methods.
You need three things before writing a line of code: a running mosparo installation, a project inside it (which gives you a UUID, public key, and private key), and PHP 8.1+ (v2.0 raised the minimum; v1.x is for older runtimes).
Install the client:
composer require mosparo/php-api-client
Wire the credentials into config/services.php and .env — never hardcode them, and treat MOSPARO_PRIVATE_KEY as a server-only secret (it is never needed by the browser):
// config/services.php
'mosparo' => [
'url' => env('MOSPARO_URL'),
'public_key' => env('MOSPARO_PUBLIC_KEY'),
'private_key' => env('MOSPARO_PRIVATE_KEY'),
'timeout' => env('MOSPARO_TIMEOUT', 5),
],
Bind the client as a singleton so Guzzle connection reuse and your timeout options apply everywhere:
// app/Providers/AppServiceProvider.php
$this->app->singleton(\Mosparo\ApiClient\Client::class, fn () => new \Mosparo\ApiClient\Client(
config('services.mosparo.url'),
config('services.mosparo.public_key'),
config('services.mosparo.private_key'),
['timeout' => config('services.mosparo.timeout'), 'connect_timeout' => 2],
));
On the frontend, render the box and initialise the widget with the public key and UUID from the project:
<div id="mosparo-box"></div>
<script src="{{ config('services.mosparo.url') }}/build/mosparo-frontend.js" defer></script>
<script>
window.addEventListener('load', () => {
new mosparo('mosparo-box', '{{ config('services.mosparo.url') }}',
'{{ config('services.mosparo.uuid') }}', '{{ config('services.mosparo.public_key') }}',
{ loadCssResource: true });
});
</script>
First use case — a contact form. In the controller, after Laravel's own validation passes, verify before doing anything with the data:
public function store(ContactRequest $request, Client $mosparo)
{
$result = $mosparo->verifySubmission(
$request->safe()->except(['_mosparo_submitToken', '_mosparo_validationToken']),
$request->input('_mosparo_submitToken', ''),
$request->input('_mosparo_validationToken', '')
);
abort_unless($result->isSubmittable(), 422, 'Spam detected.');
Contact::create($request->validated());
}
Where to look first: the README's VerificationResult section (that object is what you'll actually interrogate), then Mosparo\ApiClient\Exception to understand what is thrown versus what is returned. Rule-package methods can be ignored entirely unless you maintain a blocklist.
Gate verification behind a FormRequest, not the controller. Keep the credentials check out of individual actions by putting verification in a reusable request or a dedicated after validation hook:
class SpamProtectedRequest extends FormRequest
{
public function after(Client $mosparo): array
{
return [function ($validator) use ($mosparo) {
try {
$result = $mosparo->verifySubmission(
$this->except(['_mosparo_submitToken', '_mosparo_validationToken']),
$this->input('_mosparo_submitToken', ''),
$this->input('_mosparo_validationToken', '')
);
} catch (\Mosparo\ApiClient\Exception $e) {
report($e);
// See "fail-open vs fail-closed" in Gotchas.
return;
}
if (! $result->isSubmittable()) {
$validator->errors()->add('_mosparo', 'Your submission was flagged as spam.');
}
if ($result->hasIssues()) {
report(new \RuntimeException('mosparo issues: '.json_encode($result->getIssues())));
}
}];
}
}
Use per-field results when you need partial acceptance. getVerifiedField($key) returns one of VerificationResult::FIELD_VALID, FIELD_INVALID, or FIELD_NOT_VERIFIED. This is how you allow a submission through while quarantining, say, only the message body or a URL field:
foreach ($result->getVerifiedFields() as $field) {
if ($result->getVerifiedField($field) === VerificationResult::FIELD_INVALID) {
$suspectFields[] = $field;
}
}
Separate the user-facing rejection from the operator signal. isValid() is mosparo's raw verdict; isSubmittable() additionally asserts that verification itself completed correctly. Use isSubmittable() for the accept/reject branch and log isValid() + hasIssues() for triage — a mismatch means something went wrong on the wire, not that the user is a spammer.
Attach extra context with storeMetadata after a successful verification. Useful for correlating submissions with your own user IDs, A/B test buckets, or geo data inside the mosparo dashboard:
if ($result->isSubmittable()) {
$mosparo->storeMetadata($submitToken, $validationToken, [
'user_id' => auth()->id(),
'form' => 'contact',
]);
}
Wrap this in its own try/catch — metadata is analytics, not a gate, and a failure there should not fail the request.
Report to a dashboard with getStatisticByDate on a schedule. Cache the result; the endpoint is not designed to be hit per page view.
// App\Console\Commands\SyncMosparoStats
$result = $mosparo->getStatisticByDate(now()->subDays(30)->getTimestamp());
Cache::put('mosparo.stats', [
'valid' => $result->getNumberOfValidSubmissions(),
'spam' => $result->getNumberOfSpamSubmissions(),
'last' => $result->getLastSubmissionAt(),
'daily' => $result->getNumbersByDate(),
], now()->addHour());
Rule package maintenance belongs in queued jobs with SplFileObject. If you distribute a blocklist package, use the hash index plus batch endpoint rather than re-importing the whole package. Stream to a temp file and only then parse, so memory stays flat:
$handle = new \SplFileObject(tempnam(sys_get_temp_dir(), 'mosparo'), 'w+');
$mosparo->streamRulePackageHashIndex($packageId, $handle, function (int $megabytes) {
Cache::put('mosparo.package.progress', $megabytes, 3600);
});
$mosparo->batchUpdateRulePackage($packageId, $tasks);
For the full-content route, storeRulePackage() requires the SHA-256 hash of the payload — compute it with hash('sha256', $content) and check isHashValidated() on the result; a true from isSuccessful() alone does not guarantee mosparo accepted the hash. Paginate with getRulePackageRules($id, $page) and walk getTotalPages().
Mock the client in tests by binding a fake into the container. Because the client wraps Guzzle directly, Http::fake() will not intercept it; either bind a stub implementing the same method signatures or point the client at a local fixture server.
isSubmittable() is not isValid(). If you gate on isValid() you will accept submissions where the verification was never actually completed. Always branch on isSubmittable().
Missing tokens throw, they don't return false. verifySubmission() throws Mosparo\ApiClient\Exception when tokens are absent, when the request fails, or when mosparo returns garbage. Any catch you write must decide explicitly between fail-open (log + accept) and fail-closed (reject). For low-value forms fail-open is usually right; for anything that sends email at scale or writes to a public storefront, fail-closed with a generic "please try again" is safer. Document the choice.
Strip non-processed fields from the payload. The README is explicit: hidden inputs, checkboxes, radios, and other fields mosparo does not process must be removed before calling verifySubmission(). Passing them is a common source of FIELD_NOT_VERIFIED noise. Also drop the two _mosparo_* tokens themselves and your CSRF token — $request->safe()->except([...]) handles this cleanly.
Token names must match exactly. _mosparo_submitToken and _mosparo_validationToken, camelCase, leading underscore. Renaming them in Blade or forgetting to include the widget on a form silently produces a thrown exception on every submission — which, combined with fail-open, means no protection at all. Smoke-test that spam actually gets blocked, not just that the form submits.
PHP 8.1 minimum. On a v1.x-era Laravel app still on 7.4/8.0, v2.0.0 will not install. Pin ^1.0 deliberately or upgrade — don't let Composer resolve you into a broken lockfile.
The fourth constructor argument is raw Guzzle options, not a mosparo config array. Timeouts, proxies, verify, and retry middleware all go there. Set a connect_timeout of 2s and a total timeout of ~5s: a hanging mosparo instance will otherwise hang your form submissions, and since verification sits in the request path, its latency is your user's latency.
Local development and CI will fail unless you plan for it. Options: whitelist 127.0.0.1 in the mosparo project settings, or short-circuit verification behind a config flag that is hard-disabled in production ('bypass' => app()->environment('local')). Never bypass based on a request parameter.
getNumberOfValidSubmissions() returns a total, not a date range slice. getNumbersByDate() is keyed by date and holds numberOfValidSubmissions / numberOfSpamSubmissions per day. getLastSubmissionAt() returns null for empty ranges — guard before formatting it or you'll get a Call to a member function format() on null.
storeMetadata() throws on an invalid API response but returns bool otherwise. Treat false and the exception identically: log and continue.
Batch updates are partial by design. RulePackageBatchResult::getErrors() lists only failed tasks — everything not mentioned succeeded. Check isSuccessful(), then iterate the errors; do not assume an all-or-nothing transaction.
Small dependency surface means you should pin versions. With no dependents and a low star count, the ecosystem won't catch regressions for you. Commit composer.lock, and read the release notes before minor bumps — v2.0.0 was a PHP-version break, not a bugfix.
Debug by logging the result object, not just the boolean. getIssues() typically explains a surprising rejection in one line, and without it "spam detected" reports are unactionable for support.
How can I help you explore Laravel packages today?