AI 资讯
How to generate WCAG-compliant ALT text for WordPress images without sending them to a vendor's black-box API
If you've ever tried to fix accessibility on an old WordPress site, you know the drill: hundreds of images in the Media Library, most with empty alt attributes, and a WCAG 2.1 audit (or a client demanding one) breathing down your neck. Writing alt text by hand for 400 images is not a fun Tuesday. Every "AI alt text" SaaS I looked at wanted a monthly subscription, routed my images through their own servers, and gave me zero control over which model actually looked at the picture. This post is about the plugin I built to fix that for my own sites, and the handful of implementation details that turned out to matter more than expected. The actual problem WCAG 2.1 Success Criterion 1.1.1 requires non-text content to have a text alternative. In WordPress terms: every attachment post of MIME type image should have _wp_attachment_image_alt set to something meaningful, not "IMG_4821.jpg" and not empty. Doing this with a vision-capable LLM is trivial in principle — send the image, ask for a short description, save it as the alt attribute. The part that's not trivial, if you don't want another recurring SaaS bill and don't want to hand a third party your whole media library, is: whose API key, which model, and where does the image actually go. Design decision: BYOK, not a hosted service The plugin ( Alt Text BYOK ) doesn't call any server of mine. It calls whatever OpenAI-compatible chat/completions endpoint you configure, with your own API key. That's the entire trust model: your images go from your WordPress install directly to the provider you already chose (OpenAI, or any of the growing list of OpenAI-compatible vision endpoints), and nowhere else. The settings are deliberately just four fields: function atbyok_default_settings () { return array ( 'api_base' => 'https://api.openai.com/v1' , 'api_key' => '' , 'model' => 'gpt-4o-mini' , 'language' => 'English' , 'overwrite_existing' => '0' , 'license_key' => '' , ); } api_base is the detail that matters most for portability:
AI 资讯
Stop rewriting your API responses in Laravel (Use this Trait instead)
If you are building API-driven applications, nothing clutters up your controllers faster than manually typing out response()->json(...) arrays every single time you need to return data or throw an error. When you have inconsistent response structures, your frontend (and the developers consuming your API) will constantly have to guess whether the data is nested under ['data'] , ['payload'] , or just at the root of the object. The cleanest way I've found to standardize this across an entire application is by creating a dedicated ApiResponse trait. Instead of rewriting your JSON structure in every controller method, create this trait in your app/Traits directory: namespace App\Traits ; use Illuminate\Http\JsonResponse ; trait ApiResponse { protected function success ( mixed $data , ?string $message = null , int $code = 200 ): JsonResponse { return response () -> json ([ 'status' => 'success' , 'message' => $message , 'data' => $data ], $code ); } protected function error ( string $message , int $code = 400 , array | string $errors = []): JsonResponse { // Force errors into an array format for consistent frontend parsing $formattedErrors = is_string ( $errors ) ? [ $errors ] : $errors ; return response () -> json ([ 'status' => 'error' , 'message' => $message , 'errors' => $formattedErrors ], $code ); } } Next, simply use this trait inside your base Controller.php . Now, your actual endpoints become incredibly readable and strictly standardized: namespace App\Http\Controllers ; use App\Models\Task ; use Illuminate\Http\Request ; use Illuminate\Http\JsonResponse ; use Throwable ; class TaskController extends Controller { public function store ( Request $request ): JsonResponse { $validated = $request -> validate ([ 'title' => 'required|string|max:255' , 'description' => 'nullable|string' ]); try { $task = Task :: create ( $validated ); return $this -> success ( $task , 'Task successfully generated' , 201 ); } catch ( Throwable $e ) { // Note: Exposing raw exception messa
开发者
I Built a Discord Server Discovery Platform
I Started Building a Discord Server Directory I’ve spent a lot of time around Discord communities, and one thing has always bothered me. Finding a good Discord server is harder than it should be. There are thousands of communities for gaming, anime, roleplay, technology, social groups and pretty much every niche you can think of. But finding the right one usually means jumping between invite links, old posts, server lists and search results. At some point I thought, why not build a better way to discover them? That’s how I started working on Dizord. The first version was pretty simple. I wanted a place where a server could be listed, people could discover it, and everything could be organized around interests instead of just one giant list of servers. Then the project started getting bigger. More servers meant more categories and tags. More tags meant better search and filtering. Server information changes constantly, so keeping listings updated became another problem to solve. I’m building the backend with Laravel and working with the Discord API to handle server information and synchronization. There are also a lot of small things behind the scenes that aren't obvious when you simply open a server listing page. One of the things I'm currently working on is making discovery better for smaller communities. A server shouldn't need tens of thousands of members just to be discoverable. The goal is pretty simple: Make it easier to find a Discord community you'll actually want to stay in. The project is still evolving, but the current version is live: https://dizord.com I'm still experimenting with search, categorization, server activity and ways to make a large directory useful instead of overwhelming. If you're building a directory, marketplace, or any project with thousands of constantly changing pages, I'd also be interested in hearing how you handle discovery and indexing at scale.
AI 资讯
How I Debugged a phpMyAdmin 500 Error While Importing a Large SQL File on Laragon
I recently ran into a weird issue while working on a Laravel project on Windows using Laragon . Everything was working fine until I tried to import a database through phpMyAdmin. Instead of an SQL error, phpMyAdmin simply returned: Internal Server Error The server encountered an internal error or misconfiguration... No useful message. Just HTTP 500. My SQL file was around 97 MB , so at first I thought it was probably a PHP upload limit issue. It wasn't that simple. Here is how I debugged it. 1. Check which PHP configuration is actually running From Laragon Terminal: php --ini Then I checked the important error settings: php.exe -r "echo 'error_log=' . ini_get('error_log') . PHP_EOL;" php.exe -r "echo 'log_errors=' . ini_get('log_errors') . PHP_EOL;" php.exe -r "echo 'display_errors=' . ini_get('display_errors') . PHP_EOL;" My output was: error_log=D:/C-data/laragon/tmp/php_errors.log log_errors=1 display_errors=1 One small Laragon/Git Bash issue I also found was: type php returned: php is aliased to `winpty php.exe' Because of that, commands like: php -i | grep ... sometimes returned: stdout is not a tty Using php.exe directly avoids that problem. 2. Check the PHP error log My PHP error log was: D:/C-data/laragon/tmp/php_errors.log I reproduced the import error and checked it: tail -n 50 /d/C-data/laragon/tmp/php_errors.log Nothing useful appeared. That was an important clue. 3. Make sure browser PHP and CLI PHP use the same php.ini I created a temporary file: <?php phpinfo (); Then opened it through the browser. Important values were: Server API: CGI/FastCGI PHP Version: 8.4.4 Loaded Configuration File: D:\C-data\laragon\bin\php\php-8.4.4-nts-Win32-vs17-x64\php.ini My PHP limits were already high enough: upload_max_filesize = 512M post_max_size = 512M memory_limit = 512M max_execution_time = 36000 So the 97 MB SQL file should have been allowed by PHP. 4. Check Apache logs I located the Apache error log with: grep -Ri "ErrorLog" /d/C-data/laragon/etc/apache2 /d/C-da
AI 资讯
When `@deprecated` cries wolf: Making Shopware’s next major upgrades easier
When PHPStan reports that your extension calls a deprecated method, the expected next step is quite clear: find the replacement and migrate your code. But what if there is no replacement? Consider Context::scope() . Previously, its planned change for Shopware 6.8 was announced like this: /** * @deprecated tag:v6.8.0 - reason:new-optional-parameter - parameter $states will be added */ public function scope ( string $scope , \Closure $callback ) : mixed Static analysis sees @deprecated and reports every call to the method. However, the method is not going away. A new optional parameter will be added, so existing calls will continue to work without any changes. There is no alternative API to migrate to and no warning to resolve. In this situation, @deprecated is effectively crying wolf. With Shopware 6.7.14.0, we are changing how these planned API changes are communicated. Real deprecations remain deprecations. Other backward-compatibility changes are now described with dedicated, structured PHP attributes. The immediate result is less noise for extension developers. Additionally, the new attributes give us a foundation for preparing extensions for Shopware 6.8 - and future major releases - before those releases arrive. TL;DR Shopware now uses two different signals for two different purposes: @deprecated means that an API is obsolete and will be removed or replaced. Extension developers need to migrate away from it. BC-change attributes describe a future change to an API that remains available, such as a new parameter, a narrower return type, or a class becoming final. The attributes also distinguish between changes that affect code calling an API and changes that affect classes extending it. This means deprecation warnings become trustworthy and actionable again, while planned contract changes carry enough structured information for PHPStan, Rector, IDEs, and other tools to reason about them. We were asking @deprecated to do two different jobs The commonly understood
AI 资讯
Per-user two-factor auth in CakePHP with CakeDC/Users (opt-in, one method)
CakeDC/Users gives you TOTP two-factor authentication almost for free: flip one config key and every login grows a "enter your 6-digit code" step. The catch is that word every . The built-in flow is all-or-nothing — turn it on and all your users are forced through the OTP challenge on their next login, whether they ever set up an authenticator app or not. Lock yourself out on a fresh install and you'll find out fast. What most apps actually want is the model you see everywhere else: 2FA is off by default , and each user opts in from their own account settings. This post shows how to get there with a surprisingly small change — one overridden method — plus a self-service enrolment screen and one QR-code gotcha that will bite you on modern dependencies. The one insight: isRequired() CakeDC/Users decides whether to demand the OTP step through an OneTimePasswordAuthenticationCheckerInterface . The default implementation, DefaultOneTimePasswordAuthenticationChecker , answers "is 2FA required for this request?" — and once the authenticator is enabled in the login flow, it answers yes for everybody . That checker is a swappable dependency. So "per-user 2FA" reduces to: keep the default behaviour, but also require that this specific user has opted in. One method: <?php declare ( strict_types = 1 ); namespace App\Authentication ; use CakeDC\Auth\Authentication\DefaultOneTimePasswordAuthenticationChecker ; class PerUserOneTimePasswordAuthenticationChecker extends DefaultOneTimePasswordAuthenticationChecker { /** * @param array<mixed>|null $user User data. */ public function isRequired ( ?array $user = null ): bool { // Default rules AND the user enrolled. return parent :: isRequired ( $user ) && ! empty ( $user [ 'two_steps' ]); } } parent::isRequired() keeps every rule CakeDC already applies (the authenticator is on, the user has a verified secret, remember-me isn't skipping it, …). We just && a per-user flag on top. Users who never enrolled fail the two_steps check and log
AI 资讯
Your PrestaShop hook renders nothing, and nothing is logged
A module hook that returns an empty string looks exactly like a module hook that was never called. PrestaShop gives you nothing to tell them apart: no error, no log entry, no stack trace, no fallback text. The page renders fine. Your block is just absent. We spent three releases of one module chasing this, and the cause turned out to be three different mechanisms stacked on top of each other. Each one alone is enough to make output vanish silently. This is what they are, in the order we peeled them off. The setup The module registers displayHeader and renders a small template: a <script> block that carries a public site key into the page, and a <style> block that hides a third-party badge. Roughly: public function hookDisplayHeader ( $params ) { $this -> context -> smarty -> assign ([ 'recaptcha_pubkey' => $this -> getActivePublicKey (), 'recaptcha_hide_badge' => $hideBadge , ]); return $this -> display ( __FILE__ , 'views/templates/front/header_script.tpl' ); } Deployed, cache cleared, hook registered, Design > Positions shows the module attached. Page source: nothing. Not the script, not the style, not even a stray whitespace. Mechanism 1: core swallows the exception Hook::callHookOn() wraps every module hook call in a try/catch. When debug mode is off, it catches whatever the hook throws and returns an empty string. No error, no log, no trace. That is a defensible design decision — one broken module should not take down a storefront — but as a debugging experience it is brutal. Every possible failure inside your hook, from a typo to a missing file to a template that will not compile, arrives at your screen as the exact same symptom: nothing. The first thing to do, before theorising about causes, is to stop letting core swallow it: try { return $this -> display ( __FILE__ , 'views/templates/front/header_script.tpl' ); } catch ( Throwable $e ) { $message = 'mymodule header_script.tpl render failed: ' . $e -> getMessage () . ' in ' . $e -> getFile () . ':' . $e -> g
AI 资讯
Credits, plans and quotas in Laravel with Larameter
If your app sells an allowance, a number of credits a month, or a number of documents, or a number of anything. Whatever it is, you end up writing a balance somewhere, a reset when the period rolls over, a check before the expensive call, and a usage screen that has to agree with all of it. None of that is hard on its own. What gets you is that the pieces drift. The plan says a thousand a month, the reset runs on the first of the month, the subscription renews on the 18th, and the screen sums a table that the charging code stopped writing to two features ago. And then somebody adds a weekly cap and now there are two numbers per plan to keep consistent, times seven plans. So after repeating the same on many apps, just created Larameter. It's basically what you see in Claude or OpenAI subscription plans. It meters credits against a plan, enforces ceilings on things that exist rather than things that are spent, and works out which plan an account is on instead of storing it. How to install Just install the package via composer as usually: composer require edulazaro/larameter php artisan vendor:publish --tag = larameter-config php artisan vendor:publish --tag = larameter-migrations php artisan migrate Then add the trait to whatever you bill. An organisation, a user, a workspace: the package does not care, and it does not need a column on your table. use EduLazaro\Larameter\Concerns\HasCredits ; class Organization extends Model { use HasCredits ; } The account row appears the first time you touch it. What are allowances One period is rarely enough. A monthly figure alone lets a bad afternoon eat the month, so you want a weekly cap on top, and maybe a per-session one. Declare those windows once: 'windows' => [ 'session' => [ 'minutes' => 300 , 'anchor' => 'rolling' , 'share' => 0.04 ], 'weekly' => [ 'days' => 7 , 'anchor' => 'fixed' , 'share' => 0.25 ], 'monthly' => [ 'months' => 1 , 'anchor' => 'fixed' , 'share' => 1 ], ], And then a plan grants 1 figure , which every wi
AI 资讯
Building a Personal Blog with Laravel: A Real World Project
A personal blog sounds like a simple Laravel project. Create posts, show them on the homepage, and you are done. But once you start adding search, categories, tags, comments, SEO, authentication, analytics, and an admin panel, things become much more interesting. I built this Laravel Personal Blog as a real world project to explore those problems instead of building another basic CRUD application. The complete source code is available on GitHub: https://github.com/arafat-web/laravel-personal-blog Table of Contents What Is This Project? Technology Stack Main Features Project Structure How Visitor Analytics Works SEO and Content Management How to Run the Project What I Learned Final Thoughts What Is This Project? This is a complete single-author blogging platform built with Laravel. It includes both a public blog and a custom admin panel. The project was built without additional application packages, so most of the important functionality is visible in the codebase itself. The public side contains: Homepage Blog posts Categories Tags Search Comments RSS feed Sitemap SEO metadata Post view tracking The admin panel contains: Dashboard Post management Category and tag management Comment moderation User management General settings SEO settings Visitor analytics Technology Stack The project uses: PHP 8.3+ Laravel 13.17 MySQL or SQLite Blade Eloquent ORM JavaScript CSS PHPUnit The current project configuration requires PHP 8.3 and Laravel 13.17. Main Features The project goes beyond basic CRUD. For example, posts can have categories, tags, comments, authors, featured images, publishing status, and view counts. The Post model defines these relationships using Eloquent: public function user (): BelongsTo { return $this -> belongsTo ( User :: class ); } public function categories (): BelongsToMany { return $this -> belongsToMany ( Category :: class ); } public function tags (): BelongsToMany { return $this -> belongsToMany ( Tag :: class ); } public function comments (): HasMa
AI 资讯
Building a Custom REST API in WordPress the Right Way
WordPress is often treated as a traditional CMS, but its REST API makes it possible to use WordPress as the backend for applications, dashboards, mobile clients, automation systems, and external services. The difficult part isn't registering an endpoint. The difficult part is designing the endpoint so that authentication, authorization, validation, error handling, and data access are all handled correctly. A production API needs a contract. It needs to know: Who can access it What data they can access What input is accepted What output is returned What happens when something fails Here's a practical approach. Register a Custom Route A basic WordPress REST API route can be registered with register_rest_route() . add_action ( 'rest_api_init' , function () { register_rest_route ( 'myplugin/v1' , '/posts' , [ 'methods' => WP_REST_Server :: READABLE , 'callback' => 'myplugin_get_posts' , ]); }); This creates an endpoint similar to: /wp-json/myplugin/v1/posts The namespace matters. Using: myplugin/v1 gives the API a version boundary. If the response structure changes later, a new version can be introduced without immediately breaking existing clients. Don't Put Authorization Inside the Callback A common beginner implementation does everything inside the callback: function myplugin_get_posts () { if ( ! current_user_can ( 'manage_options' )) { return new WP_Error ( 'forbidden' , 'Access denied' , [ 'status' => 403 ] ); } // Query data... } This works, but WordPress provides a cleaner place for the permission decision. Use permission_callback . register_rest_route ( 'myplugin/v1' , '/posts' , [ 'methods' => WP_REST_Server :: READABLE , 'callback' => 'myplugin_get_posts' , 'permission_callback' => function () { return current_user_can ( 'manage_options' ); }, ]); Now the endpoint has a clearer separation: Request ↓ Permission check ↓ Callback ↓ Data That separation becomes increasingly valuable as an API grows. Authentication Is Not Authorization These concepts are easy to m
AI 资讯
The Active Flag Trap: unvalidated-but-logged-in in CakeDC/Users
If you ship email validation with CakeDC/Users , you eventually hit a question the plugin quietly hands back to you: what should happen when someone registers, never clicks the validation link, and then tries to log in? The honest answer is that CakeDC/Users doesn't decide for you. Out of the box you get a database column, a couple of behaviors, and a set of events — but the experience is yours to assemble. Get it wrong and you land in one of two bad places: a user silently logged in without ever validating, or a user who typed the right password and is told "username or password is incorrect." Neither is what you want. This post walks through why that happens in v16, and a clean way to wire the flow using the events the plugin already dispatches — no core hacks, no schema surgery. One flag, two meanings Everything starts with a single boolean column on the users table: active . When email validation is on, registration creates the account with active = 0 and only flips it to 1 when the user clicks the link in the validation email. You can trace it in BaseTokenBehavior::_updateActive() : // $user['validated'] is a transient flag set to false during register() $emailValidated = $user [ 'validated' ]; if ( ! $emailValidated && $validateEmail ) { $user [ 'active' ] = false ; // registered → inactive + token emailed $user -> updateToken ( $tokenExpiration ); } else { $user [ 'active' ] = true ; // clicked the link → active $user [ 'activation_date' ] = new DateTime (); } Notice there is no separate validated column in the database — $user['validated'] is a transient property used only during registration. The persisted truth is active , and it is doing two jobs at once: "Has this person confirmed their email?" — set by the validation flow. "Is this account enabled?" — the thing an admin toggles to ban or suspend someone. That conflation is the root of everything below. Hold onto it; we'll come back to it. How the finder decides who exists Login in CakeDC/Users runs thro
开发者
Understanding PHP-FPM's process manager (by actually watching it)
I thought I understood pm = dynamic until I sat down and watched it work in real time. It turns out the whole process manager is one tiny loop, and once you see the loop, every number in fpm-status suddenly makes sense. This is what I learned, with real outputs from a real server. The config pm = dynamic pm.max_children = 300 ; hard ceiling on total workers pm.start_servers = 5 ; workers created at boot pm.min_spare_servers = 5 ; never fewer than 5 idle pm.max_spare_servers = 50 ; never more than 50 idle pm.max_requests = 1000 ; recycle each worker after 1000 requests The whole algorithm is one loop FPM's master process runs one check, roughly every second: How many workers are idle? Fewer than min_spare_servers ? Fork one. More than max_spare_servers ? Kill one. Otherwise, do nothing. That is it. Everything below falls out of this one rule. The mystery of the 6th worker I booted FPM with start_servers = 5 and immediately saw 6 workers. Where did the extra one come from? t=0 boot 5 workers, all idle check: 5 idle. fine. t=1 a request comes in 1 busy + 4 idle = 5 total check: 4 idle < 5 min -> FORK ONE t=2 1 busy + 5 idle = 6 total check: fine. t=3 request done 0 busy + 6 idle = 6 total check: 6 > 50 max? no -> do nothing And there it stays: 6. The part everyone misses is t=3. FPM never shrinks back to start_servers . The kill rule only fires above max_spare_servers . Between the two bounds, the pool simply stays wherever demand pushed it. It ratchets up easily and trims lazily. start_servers only matters for the first second of the pool's life. Watching it happen Two seconds after a restart: start since : 2 idle processes : 4 active processes : 1 total processes : 5 The 1 active worker is my own curl. The status request is itself served by a worker, so checking the counter increments the counter. And 4 idle is already below min_spare_servers = 5 , which means the fork is about to happen. Thirty seconds later: start since : 34 accepted conn : 99 idle processes : 5 ac
AI 资讯
The excluded-plugin setting that Playwright ignored — fixing browser-mode updates and false residual warnings
The symptom In browser-mode maintenance (Playwright, no SSH), plugins marked as "excluded from update checks" were still being updated. After the run, a "plugin updates remaining" WARNING email arrived every time. The excluded plugins were intentionally left behind, but the residual check treated them as unfinished updates and fired a warning — a two-part problem: wrong behavior and a misleading alert. SSH path vs. Playwright path On SSH-capable sites, WP-CLI's --skip-plugins flag carries the ignored_plugins list into the update command. That path already excluded them correctly. The Playwright path was different. browser_update_remaining_plugins() worked by clicking the "select all" checkbox on update-core.php and submitting the form — no filtering at all. # Before: select-all, ignored_plugins never consulted page . check ( ' input[name= " action " ][value= " update-selected-plugins " ] ' ) for cb in page . query_selector_all ( ' input[name= " checked[] " ] ' ): cb . check () This function is the chokepoint for two flows: pure browser-mode updates ( run_browser_update_flow ) and the browser residual pass that runs after SSH updates ( run_browser_residual_update , on by default). So even SSH sites could have excluded plugins updated by the residual pass. Browser-driven bulk updates are also outside the scope of pinpoint rollback, meaning there is no automatic recovery if a wrong update goes through. Fix 1 — _ignored_plugin_slugs() and _plugin_slug_from_checkbox_value() When excluded plugins are configured, the fix replaces the select-all approach with per-checkbox evaluation. def _ignored_plugin_slugs ( site : dict ) -> set [ str ]: raw = site . get ( " ignored_plugins " , "" ) return { s . strip (). lower () for s in raw . split ( " , " ) if s . strip ()} def _plugin_slug_from_checkbox_value ( value : str ) -> str : return value . split ( " / " )[ 0 ]. strip (). lower () if " / " in value else value . strip (). lower () Plugin checkboxes on update-core.php use a va
AI 资讯
Single-database multi-tenancy in Symfony: a 31-line Doctrine filter, and the five places it never runs
Single-database multi-tenancy is the cheapest kind: one schema, one connection, an organization_id column on every tenant-owned table. The whole design rests on one promise, and it is a promise about forgetting : no developer on the team will ever have to remember to write WHERE organization_id = ? , because forgetting it once leaks another customer's data. Doctrine has had the tool for this for years. It is a SQLFilter , it is about thirty lines, and almost every article about it stops at the happy path. The interesting part is not the filter. It is the map of the places where it is simply not there, because that map is what you actually have to defend. Everything below is read from Doctrine ORM 3.6.7 and from a suite that runs on every commit. The filter final class OrganizationFilter extends SQLFilter { public const string NAME = 'organization' ; public const string PARAMETER = 'organization_id' ; public function addFilterConstraint ( ClassMetadata $targetEntity , string $targetTableAlias ): string { if ( ! $targetEntity -> getReflectionClass () -> implementsInterface ( OrganizationOwnedInterface :: class )) { return '' ; } return \sprintf ( '%s.organization_id = %s' , $targetTableAlias , $this -> getParameter ( self :: PARAMETER )); } } OrganizationOwnedInterface is a marker with one method, getOrganization() . An entity opts into tenancy by implementing it, and that is the entire public API of the mechanism. No attribute to remember, no base class to extend, no trait whose absence is invisible in a diff. The filter is declared in doctrine.yaml with enabled: false . That is deliberate, and it is the first design decision worth arguing about: a filter that is on by default in the container is on in your fixtures, in your migrations, in your data-repair scripts, and it will bite you at three in the morning. It gets turned on by the layer that knows who is asking. The layer that knows who is asking public static function getSubscribedEvents (): array { // Right aft
AI 资讯
How to upload a file over JSON-RPC, when JSON has no type for a file
JSON has no representation for a file. Strings, numbers, arrays, objects - that is the whole list. So every JSON-RPC API eventually runs into the same question: how do you accept a file upload - a photo, a scan, a PDF - when the protocol itself cannot carry binary data? The usual answer is: you don't. The file goes to a separate, ordinary controller that reads $request->files , and the JSON-RPC layer handles everything else next to it. And now you have exactly the ad hoc endpoint sprawl that JSON-RPC was supposed to remove. In otezvikentiy/json-rpc-api 5.2 there is a different answer. And more interesting than the feature is how it came about: I did not write it - an external contributor did. But first things first. Full disclosure: I am the author of the bundle, and I have maintained it alone for almost three years. That is exactly why a release whose headline feature was written by someone else feels like a different kind of event to me. The problem Straight from issue #8 : two services exchange scanned images plus structured metadata (tenant, station, session) on the same call - something like captures.create(tenantId, stationId, image) . Today image cannot be expressed as a parameter of a JSON-RPC method, so that call has to live outside the bundle as a separate multipart controller. What you want is for the method to simply declare a parameter of type UploadedFile and get the file, like any other parameter. The solution: multipart as a transport adapter The key idea is to leave the core untouched. A multipart/form-data request is normalized into the very same JSON-RPC envelope an ordinary request produces, only with UploadedFile objects already sitting inside params . Everything below the transport - hydration, batching, validation - stays completely unaware of multipart, exactly the way it is unaware that a GET request's payload came from a query string. The wire format: one text part named jsonrpc carries the full JSON-RPC envelope as a string (all scalar par
AI 资讯
Você criou uma tabela de tokens pra proteger PDF. O Laravel já fazia isso.
O contrato do cliente tá numa URL que qualquer um adivinha A tarefa parecia simples: o cliente precisa baixar a nota fiscal dele. Você salvou em storage/app/public/notas/ , rodou php artisan storage:link , mandou o link e foi feliz. https://app.com/storage/notas/nota-1042.pdf . Semanas depois cai a ficha. Aquele arquivo está aberto na internet . Sem login, sem nada. E o nome é sequencial: quem baixou a nota-1042.pdf só precisa de curiosidade e cinco segundos pra tentar a 1041 . E a 1040 . Então você faz a coisa certa: tira do disco público e cria um sistema pra controlar acesso. Tabela download_tokens , model, geração de UUID, coluna expires_at , controller que valida, e um comando no scheduler pra limpar os vencidos. Sessenta linhas depois, funciona. E aí alguém comenta no PR: "por que você não usou uma URL assinada?" O sistema que você não precisava construir // ❌ migration + model + controller + command. tudo isso pra um PDF. Schema :: create ( 'download_tokens' , function ( Blueprint $table ) { $table -> id (); $table -> uuid ( 'token' ) -> unique (); $table -> string ( 'path' ); $table -> foreignId ( 'user_id' ); $table -> timestamp ( 'expires_at' ); $table -> timestamps (); }); public function gerarLink ( NotaFiscal $nota ): string { $token = DownloadToken :: create ([ 'token' => Str :: uuid (), 'path' => $nota -> arquivo_path , 'user_id' => auth () -> id (), 'expires_at' => now () -> addMinutes ( 10 ), ]); return route ( 'download' , $token -> token ); } Não tem nada de errado tecnicamente. O problema é o custo: mais uma tabela crescendo pra sempre, mais um comando no scheduler, mais um caminho pra testar. E você vai manter isso enquanto o projeto existir. O Laravel resolve o mesmo problema com uma assinatura criptográfica na própria URL. Sem estado, sem tabela, sem limpeza. Como uma URL assinada funciona A ideia é bonita de simples: o Laravel monta a URL com os parâmetros que você quer, calcula um hash disso tudo usando a APP_KEY e cola o hash no final. /not
AI 资讯
Seu log tem 40 mil linhas e nenhuma resposta
"Deu erro ao salvar, umas duas da tarde" É a única informação que você tem. O cliente não lembra o que clicou, não tirou print e já fechou a aba. Você abre o laravel.log . Quarenta mil linhas no dia. Faz um grep por "erro". Aparecem 1.200 ocorrências, e a maioria é isso: [2026-08-14 14:03:11] production.INFO: entrou [2026-08-14 14:03:11] production.INFO: erro aqui [2026-08-14 14:03:12] production.INFO: passou [2026-08-14 14:03:12] production.ERROR: Erro ao salvar Erro ao salvar o quê ? De qual usuário? Qual pedido? Qual valor? Aquele entrou da linha de cima é do mesmo request ou de outro cliente que estava usando o sistema no mesmo segundo? Você tem log. Você não tem informação. São coisas diferentes. O problema não é a falta de log. É o excesso de log inútil. public function emitir ( Pedido $pedido ) { Log :: info ( 'entrou no emitir' ); try { $nota = $this -> sefaz -> emitir ( $pedido ); Log :: info ( 'emitiu' ); } catch ( Throwable $e ) { // parabéns, você registrou que algo deu errado em algum lugar 🎉 Log :: error ( 'Erro ao emitir nota' ); return back () -> withErrors ( 'Falha na emissão' ); } } Repara no que esse catch jogou no lixo: a mensagem da exceção, o stack trace, o ID do pedido, o CNPJ, o retorno da SEFAZ. Tudo estava ali, na mão, e foi substituído por uma frase genérica. E os Log::info('entrou') espalhados? Aquilo foi debug que virou permanente. Hoje eles só servem pra empurrar as linhas úteis pra fora da tela. Duas perguntas que todo log precisa responder Um log serve pra duas plateias: você, com sono, às 3h da manhã — e uma máquina , filtrando milhões de linhas. As duas querem a mesma coisa: O que aconteceu , numa mensagem que não muda nunca. Com quem aconteceu , em dados separados da mensagem. Essa separação é o pulo do gato. Repare na diferença: // ❌ mensagem única pra cada pedido. impossível agrupar ou contar. Log :: error ( "Falha ao emitir nota do pedido { $pedido -> id } do cliente { $cliente -> nome } " ); // ✅ mensagem estável + contexto est
AI 资讯
Middleware é porteiro, não gerente
Ele começou com um if . Hoje tem 80 linhas. Sabe como é: precisava barrar quem não tem assinatura ativa. Um middleware, três linhas, resolvido. Depois entrou o período de teste. Depois o plano legado que tem regra diferente. Depois "aproveita que já buscou a assinatura e desconta um crédito". Depois o e-mail de aviso quando faltam 3 dias pro vencimento. Hoje esse arquivo tem 80 linhas, faz quatro queries, altera dado no banco e dispara e-mail. Ele não é mais um middleware. É um Service que mora na pasta errada e roda em todo request. E o pior: essa regra não existe pro resto do seu sistema. O middleware que virou gerente class VerificarAssinatura { public function handle ( Request $request , Closure $next ): Response { $assinatura = $request -> user () -> assinatura ; if ( ! $assinatura || $assinatura -> venceu ()) { return redirect () -> route ( 'planos' ); } // "aproveita que já tá aqui" 🙃 if ( $assinatura -> creditos < 1 ) { return redirect () -> route ( 'planos' ) -> withErrors ( 'Sem créditos' ); } $assinatura -> decrement ( 'creditos' ); $assinatura -> update ([ 'ultimo_acesso' => now ()]); if ( $assinatura -> vence_em -> diffInDays ( now ()) <= 3 ) { Mail :: to ( $request -> user ()) -> send ( new AssinaturaVencendo ( $assinatura )); } return $next ( $request ); } } Funciona. Passa nos testes de feature. E tem quatro problemas escondidos que só aparecem meses depois. Problema 1: middleware só existe no HTTP Esse é o grande. Middleware é uma camada de request HTTP . Ela não roda em outro lugar nenhum. Então: O comando php artisan relatorio:gerar não desconta crédito. O job na fila não desconta crédito. Sua rota de API que você esqueceu de agrupar não desconta crédito. O tinker passa por cima de tudo. Você não criou uma regra de negócio. Criou uma regra da porta da frente . Qualquer outra entrada no sistema ignora ela. E, sério, isso não é hipótese: um dia alguém vai criar um endpoint novo, esquecer o middleware, e a assinatura vira um detalhe decorativo. Probl
AI 资讯
I run a surf forecast for 20 breaks in Morocco on EUR 0/month. Here's the stack.
I live on the Taghazout coast in Morocco - a strip of Atlantic between Agadir and Imsouane that's basically one long right-hand point break after another. Two years ago the only way to know if tomorrow was worth it was to check three different global forecast sites, none of which knew the difference between Anchor Point and the beach break 400m south of it. So I built taghazout.io . It now covers 20 named breaks, runs in 10 languages, and costs me nothing per month. Here's how it's actually put together - including the parts I'd do differently. The stack is deliberately boring Hand-rolled PHP. No framework, no build step, no node_modules. About 4,800 files, server-rendered, no hydration. That sounds like a confession, but it was the right call for one reason: my readers are on phones, on cafe Wi-Fi, often on 3G. A server-rendered page that ships HTML and a little CSS beats anything I could have built with a client-side framework in that environment. Time-to-content is the only metric that matters when someone is standing on the beach deciding whether to paddle out. The hosting is a cheap shared plan. The forecast data is free and open. The whole thing runs at EUR 0/month recurring , which was a hard constraint from day one. The interesting part: two ocean models that disagree The forecast blends two sources: Open-Meteo (CC BY 4.0) - the primary, with a marine endpoint that covers our coastal cells. NOAA WaveWatch III via PacIOOS - the second opinion. Here's the thing nobody tells you: they disagree, a lot. On the same hour at the same break I've seen WaveWatch read ~55% higher than Open-Meteo (1.36m vs 0.88m). Offshore models resolve coastal bathymetry badly, and our points are exactly the kind of close-in, shallow-reef setups where that bias shows up. The wrong fix is to pick one and pretend. What I did instead: Run both, cache both. Compute agreement over a 72-hour window - a Pearson correlation on the swell rhythm plus a circular difference on direction (you can'
AI 资讯
ZCPE: PHP 8.4 Certification Practice Tests
I created a ZCPE PHP 8.4 Practice Tests course 🚀 Hey PHP developers! 👋 While studying PHP in depth and working through certification-style questions, I decided to organize everything into something that could help other developers too. So I just published my first Udemy course: 🐘 ZCPE: PHP 8.4 Certification Practice Tests The idea is simple: practice, identify your weak areas, understand your mistakes, and improve your PHP knowledge. 📚 What you'll practice The questions cover topics such as: PHP 8.4 OOP Functions, arrays and strings Exceptions and error handling Namespaces Files and streams Security Databases and SQL Web features Each question includes explanations to help you understand why an answer is correct. The course is mainly designed for developers preparing for the Zend Certified PHP Engineer (ZCPE) certification, but it's also useful for anyone who wants to challenge their PHP knowledge or prepare for technical interviews. 💰 Launch price: $9.99 For the launch, I've created a $9.99 coupon valid for the next 5 days : 👉 https://www.udemy.com/course/zcpe-php-certification-practice-tests/?couponCode=B436D1CB9AEE28524E72 Coupon: B436D1CB9AEE28524E72 ❤️ Can't afford it right now? If you're preparing for the ZCPE but you're currently in a difficult financial situation and genuinely can't afford the course, send me a message and I'll give you access for free. This is my first Udemy course, so feedback from other PHP developers is very welcome! I hope it helps you on your PHP certification journey. 🐘 Thanks for reading! If you have any questions, complaints or tips, you can leave them here in the comments. I will be happy to answer! 😊😊 See you! 😊😊 Support Me Youtube - WalterNascimentoBarroso Github - WalterNascimentoBarroso Codepen - WalterNascimentoBarroso