james.xue/ali-safe-api
Laravel wrapper for Alibaba Cloud content safety (Green) API. Moderate text, images, videos, and audio for porn, politics, terrorism, ads, and more with a simple facade and config. Multiple scenes and PSR-friendly responses make it easy to plug into upload and publishing flows.
Install and wire up
composer require james.xue/ali-safe-api
php artisan vendor:publish --tag=ali-safe # confirm the tag in the README; some versions ship as ali-safe-api
Laravel 5.5+ auto-discovers the service provider and facade. Open config/ali-safe.php first — that is the file you will actually live in. It holds the AccessKey pair, the region/endpoint, and default scene settings. Everything else in the package is a thin HTTP client over Alibaba Cloud's content-moderation endpoints.
Environment
ALI_SAFE_ACCESS_KEY_ID=your-key
ALI_SAFE_ACCESS_KEY_SECRET=your-secret
ALI_SAFE_REGION=cn-shanghai
Verify the exact env variable names against the published config — Aliyun wrappers in the wild disagree between ALI_SAFE_* and ALIYUN_*. Whichever the config uses, do not hardcode keys in the file; the package reads env by default.
Where to look first
config/ali-safe.php — credentials, endpoint, timeouts.vendor/james/xue/ali-safe-api/src — this tells you the container binding key (app('ali-safe') or similar) and the exact method names.TextScan, ImageScan) and the 2022+ API (TextModeration, ImageModeration). Confirm which generation this release targets.First use case
The fastest win is moderating a single piece of text on submit — a comment, a username, a product title:
use Illuminate\Support\Facades\Log;
$result = app('ali-safe')->text($request->input('content'));
// or, if the facade alias is published: AliSafe::text(...)
Log::debug('ali-safe', ['requestId' => $result['requestId'] ?? null, 'result' => $result]);
$suggestion = $result['data'][0]['results'][0]['suggestion'] ?? 'pass';
if ($suggestion === 'block') {
abort(422, 'This content violates our community guidelines.');
}
That is a complete, shippable integration. Wrap it before you write the second call site.
Wrap the facade in a domain service. Do not let app('ali-safe') leak into controllers. The raw response is nested three levels deep (data[0].results[0].suggestion) and that shape will change your mind about testing.
namespace App\Services;
class ContentModerator
{
public function moderateText(string $text): ModerationResult
{
$text = trim($text);
if ($text === '') {
return ModerationResult::pass();
}
$raw = app('ali-safe')->text($text);
return new ModerationResult(
suggestion: $raw['data'][0]['results'][0]['suggestion'] ?? 'pass',
labels: $raw['data'][0]['results'][0]['label'] ?? null,
requestId: $raw['requestId'] ?? null,
raw: $raw,
);
}
public function moderateImage(string $url): ModerationResult
{
$raw = app('ali-safe')->image($url);
return ModerationResult::fromImage($raw);
}
}
A small ModerationResult value object with isBlocked(), needsReview(), isClean() pays for itself immediately — because suggestion has three values, not two, and you will make the review-queue decision in a dozen places.
Use it as a validation rule for user-generated text.
class PassesModeration implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$result = app(ContentModerator::class)->moderateText((string) $value);
if ($result->isBlocked()) {
$fail('This content violates our community guidelines.');
}
if ($result->needsReview()) {
// Don't reject — flag. Store the requestId on the model.
request()->attributes->set('moderation_review', $result);
}
}
}
For images, moderate the URL after upload, not the upload itself. The package sends a URL to Aliyun; Aliyun fetches it. So the flow is: accept the upload → store to OSS or S3 with a publicly reachable (or long-signed) URL → queue a moderation job → flip the approved flag when the job completes.
class ModerateImageJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = [10, 60, 300];
public function __construct(public Media $media) {}
public function handle(ContentModerator $moderator): void
{
$result = $moderator->moderateImage($this->media->publicUrl());
$this->media->update([
'moderation_status' => $result->isBlocked() ? 'blocked' : 'approved',
'moderation_request_id' => $result->requestId,
]);
}
}
Persist the result. A moderations table (subject_type/subject_id, suggestion, labels, request_id, raw JSON) gives you an audit trail, makes support tickets tractable, and turns "why was this user blocked" into a query instead of a scroll through logs. Aliyun's console retention is not indefinite.
Cache by content hash. Identical text and image URLs are common on re-submits and edits.
$key = 'moderation:text:' . hash('xxh128', $text);
$result = Cache::remember($key, now()->addDay(), fn () => $moderator->moderateText($text));
Only do this for pass/block. Never cache a review result behind a TTL — reviews need human eyes.
Testing. Bind a fake in your test suite rather than hitting the network:
$this->mock(ContentModerator::class, function ($mock) {
$mock->shouldReceive('moderateText')
->andReturn(ModerationResult::blocked('ad', 'req-123'));
});
If you want to test the wrapper's response parsing rather than your service layer, use Http::fake() with a captured real response body. Capture one real response per scenario (text pass, text block, image block, image review) into tests/fixtures/ once and replay them forever.
code === 200 does not mean "clean". The top-level code is the Aliyun API call status. The verdict is buried in data[*].results[*].suggestion — pass, review, or block. Treating a 200 as a pass is the single most common way to ship this broken.
Never collapse review into pass. The default Aliyun thresholds will route borderline content to review. On UGC this is a nontrivial percentage. If you don't have a review queue, you are effectively approving it — decide that consciously, don't let a null-coalesce decide it for you.
Aliyun must be able to fetch your image URL. A private S3 bucket URL will fail with a generic error that looks like a moderation failure. Either use an OSS bucket with public-read on a dedicated prefix, or generate a signed URL with an expiry longer than the API timeout. Localhost/CI URLs obviously cannot be moderated — skip the call in those environments.
RAM permissions, not root keys. The AccessKey needs at least the Green/Content-Moderation permissions (AliyunGreenFullAccess for a start, then narrow it). A 403 InvalidAccessKeyId.NotFound or Forbidden.RAM from Aliyun means the key exists but the policy doesn't — not that the key is wrong.
Region/endpoint mismatch produces confusing errors. The Green endpoint is region-specific (green.cn-shanghai.aliyuncs.com, green.cn-beijing.aliyuncs.com, etc.). If your other Aliyun services are in cn-hangzhou but the moderation endpoint is cn-shanghai, that's fine — but keep the config explicit rather than relying on a shared ALIYUN_REGION.
Empty and whitespace-only strings. Some Aliyun endpoints error on empty input; some return a 200 with an empty results array. Short-circuit before the call. Same for very long text — moderation APIs cap input length and silently truncate, so chunk long posts (a few thousand characters at a time) and re-check each chunk.
Keyword detection depends on console configuration. The keyword filter only fires for libraries you have configured in the Aliyun Content Moderation console. If keyword hits never appear in results, the credential is fine — the console has no keyword library attached to that account/business. This is a config problem, not a code problem.
Set an explicit timeout and retry deliberately. The default Guzzle timeout on a cold endpoint is tight. Set 5–10s in the config, retry on 5xx and connection errors only, and let 4xx bubble up — retrying Forbidden.RAM just burns money.
Cost and QPS. Every call is billable and rate-limited. Queue image moderation; don't moderate inline on a bulk import. If you're moderating an existing table, write a one-off Artisan command with ->chunkById() and a small sleep.
Log the requestId. It is the only identifier Aliyun support will accept. Put it in your moderation table and in the log context at the call site.
php artisan config:cache after any env change. The classic "it works locally but not on staging" for this package is a stale config cache holding null credentials.
Extension points. The container binding is your seam: bind ContentModerator (your class) in a service provider and inject that everywhere. If you later swap providers (Tencent, AWS Rekognition, a self-hosted model), only the one class changes. Avoid binding the package's own client class directly — that couples your app to an SDK generation that has already changed once.
Multiple Aliyun packages in one app can collide on facade aliases and shared env vars. Namespace your env keys (ALI_SAFE_*) and check config/app.php aliases if a facade resolves to the wrong class.
How can I help you explore Laravel packages today?