A plugin runs with full access to the site. These are the rules the bundled modules follow, and the places where TruCMS behaves differently from a plain Laravel application.
permission:access testimonials) and a ->can('...') on each route. A route without a check is open to every signed-in user.['web', 'auth', 'verified', '2fa', AdminTheme::class] gives your screens the same e-mail-verification and two-factor protection as the core admin.$request->user() or write a policy, as the core does for teams and tickets.userWithPermissions(), not with a super admin, or missing checks go unnoticed.authorize() method too when the route is shared.$fillable on models and pass $request->validated(), never $request->all().orderBy() column names without an allow-list.@csrf to every form that posts.throttle:5,1 or a named limiter).Endpoints called by outside services cannot carry a CSRF token. Exclude the route from request-forgery protection and authenticate the caller instead:
Modules/Testimonials/routes/front.php
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
Route::post('webhooks/review-service', ReviewWebhookController::class)
->withoutMiddleware([PreventRequestForgery::class])
->middleware('throttle:60,1')
->name('webhooks.review-service');In the controller, verify the provider's signature (an HMAC of the raw body with a shared secret, compared with hash_equals()) before reading anything else, and make the handler safe to receive the same event twice. Payment gateways do not need their own route: TruPayments' payments/webhook/{gateway} calls your gateway, which verifies the signature.
{{ }}. Use {!! !!} only for HTML you produced or cleaned yourself.sanitize_html() only strips tags; it keeps attributes such as onclick and href="javascript:...". It is not a defence against cross-site scripting.App\Support\TopbarHtml::clean() keeps a handful of tags and TopbarHtml::href() accepts only safe link schemes.style attributes and inline scripts need their own validation (a colour regex, @json() for data). Escaping alone is not enough there.Saved pages are compiled with Blade::render() every time they are shown. That is what makes shortcodes work, and it means stored content is executable template code.
{{ and @php with a zero-width space.CacheProfile::keepOutOfCache()).data-bind-attr refuses href, src, style, class, id, on* and srcdoc. Keep that list if you extend binding.HasMedia, addMediaFromRequest()) with mimes/mimetypes and max rules on the request.fileUpload() helper keeps the client's file name, so a second upload with the same name replaces the first, on a public disk. Generate your own names for anything users upload..env values are stored in plain text. The settings password field only masks typing in the browser.Crypt::encryptString() as the AI assistant's secret store does, and never send them back to the browser.manage settings or your own).Http::timeout(10)).https, block private and loopback addresses, cap redirects and size. The template importer's RemoteAssetFetcher is the model to follow..trucms.zip) are the opposite: data only. If your plugin contributes to them, never put Blade, PHP or dynamic shortcode attributes in packaged content; the importer rejects packages that contain them.On demo sites (APP_DEMO=true) the demo accounts may not save anything. The guard recognises write routes by name (admin.*.store, .update, .destroy, .delete…), so name your routes conventionally and they are covered automatically.