symfony

Stars and mentions

Last 30 days, Oct 4 – Nov 2

GitHub stars158
mentions13
0481216Oct 4Oct 18Nov 2
UTC days · repositories symfony owns11 repositories

Package downloads

Cumulative recorded downloads · since 2025-10-31

npm10.1M
02.5M5.1M7.6M10.1MOct 4Oct 18Nov 2
Oct 29npm: 1/1 packages observed

Across 1 known packages in repositories symfony owns. Cumulative totals include recorded downloads before the selected range. Missing observations leave gaps; totals may be incomplete.

Top repositories

symfony’s repositories ranked by new stars, as on trending.

Loading

Contributes to

Repositories outside symfony’s account where symfony opened or reviewed pull requests in the past 365 days.

Who noticed symfony

Everyone who starred or forked symfony’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

13posts
Nov 30 posts
  1. 7.7x Smaller and Encrypted at Rest: Compressing Symfony Messenger Payloads Without Touching a Handler Symfony Messenger serializes a message, hands the bytes to a transport and forgets about them. Two things routinely go wrong with those bytes: 1. They are too big. A bulk export, a sync job with a few thousand rows, a report with embedded data, and you're past SQS's 256 KB limit or your Redis memory is going somewhere it shouldn't. 2. They are readable. Names, emails, IBANs sit in plain text in messenger_messages, in a Redis RDB dump, on a RabbitMQ disk and in the dead-letter queue nobody has looked at since March. mattleads/messenger-compress-bundle fixes both at the transport-serializer layer. You opt in per message with a stamp or enforce a policy per transport, and your handlers receive the same plain PHP objects they always did. We'll walk through the whole thing on a small Doctrine (SQLite) transport, because then the queue is a table you can SELECT from and see what changed. Prerequisites - PHP 8.2 or newer. ext-zlib and ext-sodium ship with PHP, so there is nothing to install. - A Symfony 6.4, 7.x or 8.x application with symfony/messenger. The outputs below come from Symfony 8.1. - mattleads/messenger-compress-bundle v0.1.1. - To follow along exactly, clone the demo. It uses a Doctrine transport on SQLite. 1. Install composer require mattleads/messenger-compress-bundle Symfony Flex registers the bundle. Without Flex, add it yourself: // config/bundles.php return [ // ... MattLeads\MessengerCompressBundle\MattLeadsMessengerCompressBundle::class => ['all' => true], ]; Nothing happens yet. The bundle registers a serializer service, mattleads_messenger_compress.serializer, that wraps Messenger's default serializer. Until a transport uses it, your messages are untouched. 2. Wire it to a transport You have two options. Start with one transport: # config/packages/messenger.yaml framework: messenger: failure_transport: failed transports: async: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' serializer: mattleads_messenger_compress.serializer failed: 'doctrine://default?queue_name=failed' routing: 'App\Message\GenerateInvoiceBatch': async 'App\Message\ExportCustomerData': async Or wrap the default serializer so every transport gets it: # config/packages/mattleads_messenger_compress.yaml mattleads_messenger_compress: decorate_default_serializer: true One tip for the walkthrough only: Messenger's default serializer is PHP serialize(). If you switch the default to the JSON one, plain bodies become human-readable in the database, which makes the "before" picture much clearer: framework: messenger: serializer: default_serializer: messenger.transport.symfony_serializer The bundle doesn't care which one you wrap. It compresses and encrypts bytes. 3. See the problem first Here is a message you would not want sitting in a queue table: final readonly class ExportCustomerData { public function __construct( public string $customerId, public string $fullName, public string $email, public string $iban, ) {} } Dispatch it as usual and look at the row. In the demo, app:queue:inspect --grep <string> does this. By hand it's just SQL: SELECT id, length(body), substr(body, 1, 60), headers FROM messenger_messages; id message X-Compression X-Encryption key encoding body bytes body preview 1 ExportCustomerData - - - - 119 {"customerId":"cust-4711","fullName":"Nino Beri… [ERROR] "GE29NB0000000101904917" is readable in 1 stored message(s). The IBAN is right there. Every backup of that database has it too. 4. Encrypt it Generate a key bin/console messenger-compress:generate-key -v k3Jm…(44 characters of base64)…= Add to .env.local (never commit it): MESSENGER_ENCRYPTION_KEY_V1=k3Jm… and to config/packages/mattleads_messenger_compress.yaml: encryption: { enabled: true, keys: { v1: '%env(MESSENGER_ENCRYPTION_KEY_V1)%' } } That's 32 random bytes, base64-encoded. Put it in .env.local for development and in your secrets store for production. Symfony's own vault works fine: bin/console secrets:set MESSENGER_ENCRYPTION_KEY_V1 Configure # config/packages/mattleads_messenger_compress.yaml mattleads_messenger_compress: encryption: enabled: true keys: v1: '%env(MESSENGER_ENCRYPTION_KEY_V1)%' Dispatch with a stamp use MattLeads\MessengerCompressBundle\Stamp\EncryptStamp; $bus->dispatch(new ExportCustomerData(...), [new EncryptStamp()]); Same row, after: 2 ExportCustomerData - xchacha20poly1305 v1 base64 212 jUa0PNnAtVBUT5+uTPOOxsbUrOTsIg9cYKnD1WAKsnRQEyN… The body is XChaCha20-Poly1305 ciphertext (libsodium, fresh 192-bit nonce per message, authenticated). The headers are still readable: the message type, the scheme, the key id. That's deliberate: the consumer needs them to know what to do and Messenger needs the type to route the message. Which brings up the one rule worth memorizing, call it the header rule: stamps travel as headers and are not encrypted. Put secrets in the message body, never in a stamp. Notice the size went up: 119 bytes became 212. A 24-byte nonce, a 16-byte tag and base64's 33 % overhead cost more than a tiny body saves. That's fine. Encryption is a decision about sensitivity, not about size. Or encrypt everything on this transport If a transport only ever carries sensitive work, stop thinking about stamps: mattleads_messenger_compress: encryption: enabled: true always: true keys: { v1: '%env(MESSENGER_ENCRYPTION_KEY_V1)%' } A single message can opt out with PlainStamp. An explicit EncryptStamp always wins over policy. 5. Compress the big ones Now the other problem. GenerateInvoiceBatch with 5,000 order lines serializes to 759 KB of JSON. Three ways to shrink it: Explicitly, per message: use MattLeads\MessengerCompressBundle\Stamp\CompressStamp; $bus->dispatch($batch, [new CompressStamp()]); // configured default: deflate $bus->dispatch($batch, [new CompressStamp('gzip')]); $bus->dispatch($batch, [new CompressStamp('zstd', 3)]); // algorithm + level; zstd needs ext-zstd Automatically above a size. This is the default to pick, because nobody remembers to add a stamp to the one message that grows: mattleads_messenger_compress: compression: default_algorithm: deflate # deflate | gzip | zstd level: null # null = the algorithm's default auto_threshold: 4096 # any body >= 4 KiB is compressed, no stamp needed Always: mattleads_messenger_compress: compression: always: true Two details of the automatic policies: they only keep the compressed body when it is actually smaller than the original (random blobs and already-compressed data pass through untouched) and PlainStamp opts a message out. An explicit CompressStamp is always honored even if the result is larger. What it looks like with auto_threshold: 4096: | Message | Stamps | Raw | On the wire | Ratio | |---|---|---|---|---| | 50 invoice lines | none (auto) | 7.6 KB | 1,416 B | 18.6 % | | 5,000 invoice lines | none (auto) | 758,571 B | 98,060 B | 12.9 % | | 5,000 invoice lines | CompressStamp('gzip'), EncryptStamp('v2') | 758,571 B | 98,136 B | 12.9 % | | Customer export | EncryptStamp() | 119 B | 212 B | 178 % | The third row is the common production combination: compress and encrypt. The bundle always compresses first and encrypts second regardless of the order of your stamps, because ciphertext does not compress. The 76 bytes between rows two and three are the gzip header plus the nonce and tag. > One caveat from the CRIME/BREACH family: compressed-then-encrypted length can leak information about the plaintext. If a single message mixes attacker-controlled input with a secret and the length is observable to that attacker, do not compress it. For almost every queue job this isn't a concern, but it's worth knowing. 6. What goes over the wire The bundle adds up to four headers to the encoded envelope. Everything else (the type header, the stamp headers) is whatever the inner serializer produced. | Header | Value | Meaning | |---|---|---| | X-Compression | deflate | gzip | zstd | body was compressed with this algorithm | | X-Encryption | xchacha20poly1305 | body was encrypted with this scheme | | X-Encryption-Key-Id | e.g. v1 | which key, so consumers can rotate | | X-Payload-Encoding | base64 | body is base64 text | Base64 is the default because the Doctrine, Redis and SQS transports all want text bodies. On AMQP you can drop it and save the 33 %: mattleads_messenger_compress: encoding: raw Because everything is in headers and the body is self-describing, a consumer with the bundle installed decodes any mix of plain, compressed, encrypted and compressed-and-encrypted messages from the same queue. You can roll the bundle out to consumers first and start stamping messages later. 7. Consume, and get metrics for free Nothing changes in the handler: #[AsMessageHandler] final readonly class ExportCustomerDataHandler { public function __invoke(ExportCustomerData $message): void { // $message is the plain object. Decryption happened in the transport serializer. } } But the envelope your handler is wrapped in now carries a TransformedStamp describing what the serializer undid: compression, encryption, keyId, wireSize, rawSize and a ratio() helper. It implements NonSendableStampInterface, so it is never sent back out if the message is re-dispatched. A tiny listener turns it into logs or metrics: use MattLeads\MessengerCompressBundle\Stamp\TransformedStamp; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\Messenger\Event\WorkerMessageReceivedEvent; final readonly class TransformedStampLogger { public function __construct(private LoggerInterface $logger) {} #[AsEventListener] public function __invoke(WorkerMessageReceivedEvent $event): void { $stamp = $event->getEnvelope()->last(TransformedStamp::class); if (null === $stamp) { return; // message was neither compressed nor encrypted } $this->logger->info('compression={c} encryption={e} key={k} wire={w} raw={r} ratio={ratio}', [ 'c' => $stamp->compression ?? '-', 'e' => $stamp->encryption ?? '-', 'k' => $stamp->keyId ?? '-', 'w' => $stamp->wireSize, 'r' => $stamp->rawSize, 'ratio' => sprintf('%.1f%%', 100 * $stamp->ratio()), ]); } } $ bin/console messenger:consume async --limit=5 Received ExportCustomerData: compression=- encryption=xchacha20poly1305 key=v1 wire=212 B raw=119 B ratio=178.2% Handled export for cust-4711 (Nino Beridze, iban ends 4917) Received GenerateInvoiceBatch: compression=deflate encryption=- key=- wire=98,060 B raw=758,571 B ratio=12.9% Handled batch-5000: 5000 lines, total 15,537,656.65 EUR Push ratio() and wireSize into your metrics backend tagged by message class and you've got a dashboard of what each job actually costs your broker. 8. Rotate keys without draining the queue Keys leak, people leave, policies say "every 90 days". Rotation is the part most home-grown encryption layers get wrong, because there's always a message in flight encrypted with the old key. The bundle's answer is a key ring. Every encrypted message carries its key id in X-Encryption-Key-Id; consumers decode with whichever key the message names and active_key says which key new messages use. mattleads_messenger_compress: encryption: enabled: true active_key: '%env(MESSENGER_ACTIVE_KEY)%' keys: v1: '%env(MESSENGER_ENCRYPTION_KEY_V1)%' v2: '%env(MESSENGER_ENCRYPTION_KEY_V2)%' The procedure, in order: 1. Generate v2 (messenger-compress:generate-key --id v2 -v) and deploy it to every consumer alongside v1. Nothing changes yet; active_key is still v1. 2. Flip active_key to v2 on producers. New messages are v2; consumers still decode the v1 backlog. 3. Once the queues (including the failure transport) have cycled, remove v1. You can watch it happen in the demo by running the producer with one key and the consumer with the other: php bin/console app:dispatch --export --encrypt # active_key=v1 MESSENGER_ACTIVE_KEY=v2 php bin/console app:dispatch --export --encrypt MESSENGER_ACTIVE_KEY=v2 php bin/console messenger:consume async --limit=2 Received ExportCustomerData: … encryption=xchacha20poly1305 key=v1 … Received ExportCustomerData: … encryption=xchacha20poly1305 key=v2 … If active_key is omitted, the last key in the map is used. 9. When it goes wrong: wrong key, tampered body This is the part to understand before production. Decryption is authenticated, so a message encrypted with a key the consumer doesn't have, or modified in the broker, is refused rather than handed to your handler as garbage. Let's corrupt a stored body by hand: php bin/console app:dispatch --export --encrypt sqlite3 var/data.db "UPDATE messenger_messages SET body = substr(body,1,40) || 'X' || substr(body,42) WHERE delivered_at IS NULL;" php bin/console messenger:consume async --limit=3 php bin/console messenger:failed:show Error thrown while handling message MessageDecodingFailedException. Sending for retry #1 using 93 ms delay. Error: "Decryption failed: authentication tag mismatch (wrong key or tampered payload)." Error thrown while handling message MessageDecodingFailedException. Sending for retry #2 using 110 ms delay. … Error thrown while handling message MessageDecodingFailedException. Removing from transport after 2 retries. … There is 1 message pending in the failure transport. Id Class Failed at Error 4 Symfony\Component\Messenger\Exception\MessageDecodingFailedException 2026-11-01 15:12:53 Decryption failed: authentication tag mismatch (wrong key or tampered payload). So: the worker doesn't crash, the handler never runs, the retry strategy you configured applies and the message is parked on the failure transport with the original bytes intact. That last point matters for the "wrong key" case. Suppose a consumer was deployed without v2 and some v2 messages failed. Add the key and replay: php bin/console messenger:failed:retry --force Handled export for cust-4711 (Nino Beridze, iban ends 4917) The failed message is re-sent through the serializer untouched (it isn't compressed or encrypted a second time), decoded with the now-present key and handled normally. A note on Symfony 8.1. Messenger 8.1 changed how a transport serializer must report a message it cannot decode: it has to return an envelope wrapping MessageDecodingFailedException instead of throwing, so the stamps that count retries survive. Bundle v0.1.0 threw and on 8.1 that meant "retry #1" forever with an empty failure transport. The scenario above is how I found it; v0.1.1 returns the wrapped failure. The official custom-serializer example in the Symfony docs now shows the same pattern (symfony-docs#23459) and the throwing path itself is tracked in symfony/symfony#67791, with a one-file reproducer. The errors you can see and what they mean: | Message | Cause | |---|---| | Decryption failed: authentication tag mismatch (wrong key or tampered payload). | The key with that id exists on the consumer but is different from the producer's or the body was modified. | | No encryption key with id "v2" is configured. Known ids: v1. | The consumer has not received the new key yet. Rotation step 1 was skipped. | | Received an encrypted message but encryption is not configured on this consumer. | encryption.enabled is false on the consumer or it has no keys. | | Message requested encryption but mattleads_messenger_compress.encryption is not enabled / has no keys. | Producer side: you dispatched with EncryptStamp before configuring a key. | | Unknown compression algorithm "zstd". Available: deflate, gzip. | ext-zstd is not loaded on this host. | 10. Testing Your handler tests do not change; they never saw the serializer. For an integration test that exercises the real round trip, the in-memory transport can run the serializer like a broker would: # config/packages/test/messenger.yaml framework: messenger: transports: async: dsn: 'in-memory://?serialize=true' serializer: mattleads_messenger_compress.serializer Dispatch with stamps, consume, assert on the TransformedStamp of the received envelope. This is how the bundle's own functional tests work. 11. Production checklist - Keys come from a secrets store, never from the repository. secrets:set, Vault, SSM, whatever you already use. - Consumers before producers. Install the bundle on consumers first; add keys to consumers first; only then stamp or switch active_key. - Prefer a threshold over stamps. auto_threshold: 4096 catches the message that grows six months from now. Reserve explicit stamps for zstd/level tuning. - Encrypt by transport if you can. A dedicated sensitive transport with encryption.always: true is easier to audit than stamps scattered through services. - Headers are plain text. Message class, stamp contents, key id are all visible. Keep secrets in the body. - Keep the failure transport in mind during rotation. Messages parked there may still be on the old key; do not remove it until the failure transport is empty or replayed. - Watch ratio(). If a message class is consistently above 100 %, it's small and doesn't need compression; if it is consistently at 100 %, the data is already compressed (images, archives) and PlainStamp saves CPU. Where to go from here The bundle is on Packagist and GitHub (MIT, Symfony 6.4–8.x, PHP 8.2+). The runnable walkthrough with the inspect command, the listener and the tamper scenario is at mattleads/messenger-compress-demo. Custom compressors (Brotli, LZ4) plug in by implementing CompressorInterface and are picked up by the mattleads_messenger_compress.compressor tag. Custom encryptors implement EncryptorInterface. If you run this against SQS, Redis or AMQP at volume, open an issue with the ratios you see and anything that surprised you. That's what the issue tracker is for. Source Code: You can find the full implementation and follow the project's progress on GitHub: mattleads/messenger-compress-bundle Let's Connect! If you found this helpful or have questions about the implementation, I'd love to hear from you. Let's stay in touch and keep the conversation going across these platforms: - LinkedIn: linkedin.com - X (Twitter): x.com - Telegram: t.me - GitHub: github.com/mattleads

  2. “Should X develop an ORM?” No. This answer is always NO. symfony#67534

  3. The ghost in my tests was a carriage return This is a submission for the Hacktoberfest Open-Source AI Challenge: Week 4 - Ghost in the Machine. What I Built On Windows, a reStructuredText file came back from a loader as one single section. On Linux, the same file split at every heading. The file used \n line endings. The loader split on PHP_EOL, which is \r\n on Windows. It was looking for a separator that wasn't there. That is my kind of ghost: something you cannot see in the editor, but that changes what the program does. An AI coding agent helped produce five fixes for these kinds of bugs in symfony/ai from my Windows machine. Four involved PHP_EOL being used to parse text from somewhere else. The fifth involved a probe that recognized php but not php.exe. Those bugs inspired poltergeist, a local tool with two parts. The scanner finds hidden characters. Carriage returns, byte order marks, zero-width spaces, non-breaking spaces, bidirectional overrides, mixed-script words and Unicode tag characters. Some tag characters render invisibly while encoding text, which makes them particularly interesting in files an agent reads. poltergeist scan exposes those characters. poltergeist exorcise previews changes as a dry-run diff, making the hidden characters visible before anything is removed. The model investigates platform assumptions. A hard-coded executable name, an implicit encoding or a filename with the wrong capitalization can look perfectly ordinary. The model reads the code and the reported symptom, suggests a category and proposes a small fix. The scanner works without AI. The diagnostic part uses small, local open-weight models. Demo Open the demo page. The page shows captured command output and benchmark results. It does not run inference in your browser. From a checkout of the repository, you can generate examples and inspect them: python scripts/make_examples.py haunted/ python -m poltergeist scan haunted/ python -m poltergeist exorcise haunted/ --eol lf The last command previews the changes. Code GitHub: Yupsecous/poltergeist The Python tool uses the standard library. Local model inference runs separately through llama-server from llama.cpp. The project includes 68 tests, including a check for unexpected hidden characters in its own repository. How I Built It The model comparison used: - Gemma 3 1B - Gemma 3 4B - Qwen2.5 3B All three ran as 4-bit GGUF models on a CPU-only desktop with an i7-8700 and 64 GB of RAM. Each model received code and a symptom. It had to choose one of five categories: 1. Line endings 2. Invisible characters 3. Paths and the file system 4. Executable naming 5. Encoding and locale It also had to explain the problem and suggest a fix. There were two prompt versions. The plain prompt contained the code and symptom. The grounded prompt also exposed hidden characters, included scanner findings and supplied up to three entries from a short, hand-written collection of platform facts. The inference client accepts only loopback addresses and ignores proxy settings. That behavior has a test. I did not disconnect the computer from the network, so I am not claiming a physically isolated setup. The benchmark that looked too good The first benchmark contained five cases based on the Symfony fixes and ten synthetic cases. The platform-facts lookup classified all fifteen correctly without using a model. That looked impressive until you considered how it was built: the facts had been written with those cases already available. It was measuring how well the lookup matched examples that had shaped it. The lookup was then frozen in a commit. Ten additional cases were written afterward, using other languages and different wording. On those ten new cases, the lookup scored 3 out of 10. That became the more useful comparison. | Approach | Correct category on 10 new cases | Fix passed keyword rubric | Median seconds per answer | | --- | --- | --- | --- | | Platform-facts lookup, no model | 30% | Not scored | Not measured | | Gemma 3 1B, plain / grounded | 30% / 30% | 10% / 30% | 6.1 / 6.7 | | Qwen2.5 3B, plain / grounded | 80% / 90% | 30% / 50% | 11.3 / 10.2 | | Gemma 3 4B, plain / grounded | 40% / 40% | 30% / 30% | 17.5 / 20.1 | One case is ten percentage points here. These are small exploratory results, not a general ranking of the models. The proposed fixes were checked with a keyword rubric. They were not executed to prove that they fixed the bug. What the results actually showed The largest model did not perform best on these cases. Qwen2.5 3B classified eight of the ten new cases correctly with the plain prompt and nine with grounding. Gemma 3 4B classified four correctly with either prompt. That is an observation about this benchmark, not evidence that one model is better at software debugging in general. Grounding helped some proposed fixes without consistently improving classification. Gemma 3 1B went from one to three fixes passing the keyword rubric. Qwen went from three to five. Gemma 3 4B stayed at three. On the original fifteen cases, grounded Qwen and Gemma 4B both reached 100% classification accuracy. But the lookup already scored 100% there. Those results do not establish that the models independently diagnosed the bugs. A correct category could hide a wrong explanation. One new case involved two filenames differing only in letter case. The smallest model chose the correct category, but explained it as a path-separator problem. The other two models blamed line endings and incorrectly claimed that macOS uses CRLF. The category score did not capture that failure. This is why the raw answers matter alongside the table. The cases are small and come from one project. The benchmark is a smoke test that helps expose limitations, not proof that these models can safely repair arbitrary code. The ghosts inside the project itself The tool encountered the same problems it was supposed to diagnose. Hidden characters in its own source. The first self-scan reported 32 findings. Some intended textual escape sequences had been written as actual invisible characters. The code still ran. Those characters were replaced with visible escapes, and a repository check was added. An encoding failure in the test tooling. A mutation-test script crashed when Python on Windows decoded a child process's output as cp1252. The tool checking the tool had its own platform assumption. A benchmark process-management bug. PowerShell variable names are case-insensitive. A $server variable overwrote a -Server parameter, disrupting cleanup and leaving a model server running. That could have caused a later model's requests to reach the earlier server. The script now refuses to start if the port is already occupied. A scanner that quietly skipped a file. Windows PowerShell 5.1 saved a results table as UTF-16 with a byte order mark. The repository check caught it, but the scanner had silently skipped the file because it could not decode it. The scanner now reports text files it could not read. That last change matters to me: a quiet scan should not be confused with a complete scan. Why Does Open Innovation Matter? The material needed to diagnose these bugs can be sensitive: source code, configuration files and internal logs. Running an open-weight model locally lets the diagnostic workflow use that material without requiring a hosted inference service. The client code enforcing the loopback restriction is available to inspect. Open models also made comparison practical. The same cases could be run through three different models, with the answers retained for inspection. There was no need to assume the biggest model would be the best fit. The runs used temperature zero, a fixed seed and a JSON output schema. The 1B model was run twice across all 25 cases and both prompts. All 50 answers matched, including their explanations, fixes and token counts. Timings differed. Only the smallest model received that repeatability check. It does not establish reproducibility across different machines or llama.cpp builds. The useful outcome was being able to inspect where the approach worked and where it failed. My Agent Session An AI coding agent, Claude Code, wrote most of the code and tests for poltergeist. I ran the benchmark in my own terminal on Windows. The agent also produced the five Symfony AI fixes that inspired the project. Agent assistance did not prevent mistakes. The hidden characters, PowerShell variable collision and skipped-file behavior all appeared during development. The project takes untrusted text as input. Its scanner can expose some suspicious characters before an agent reads them, but it is not a complete prompt-injection defense. An instruction does not need invisible characters to be malicious. The Spirit of It The carriage return comes from a physical machine moving a print head back across a page. Decades later, that old behavior can still break a modern loader because two systems disagree about what ends a line. That connection to the past became the project's Halloween theme: invisible remnants and assumptions haunting otherwise ordinary files. The scanner makes some of them visible. The local model suggests where else to look. Sometimes it points at the wrong wall. Keeping those failures in the write-up is part of showing what the tool actually does. Prize Categories Best Use of Gemma The project runs Gemma 3 1B and 4B locally through llama.cpp and compares their diagnostic outputs with another open-weight model and a non-model lookup baseline. The results include both the improvements and the cases where Gemma struggled.

    2 reactions
  4. Reddit · · r/symfony · u/Jacomeit · symfony/ai

    GuardedChatBundle: guardrails for public AI chat endpoints on top of Symfony AI Disclosure: I wrote this. If you put a Symfony AI agent behind a public, anonymous chat endpoint, the model call is the easy part. The work is everything around it: bots draining your token budget, session handling, retention, and a way to switch the chat off when something goes wrong. I extracted what I built for my own site into a bundle, stejac/guarded-chat-bundle. What it does: - a prefilter for obvious prompt-injection phrases, as a plain input processor, so the model is never called for those - rate limits per client, a message cap per session and a budget stop - signed session tokens with a lifetime and an idle timeout - versioned prompts in the database, editable without a deploy - a two-stage kill switch and scheduled retention for old conversations - a Stimulus chat widget with a notice that an AI is answering It complements Symfony AI instead of replacing it. When a guardrail PR for core was closed, the maintainers suggested a standalone package on top of symfony/ai-agent, so this is one take on that. I wrote up a few observations from building it in symfony/ai#1524: ai#1524 One caveat: the phrase filter is no security boundary, it only catches the obvious attempts. Rate limits and budget caps matter more on a public endpoint. PHP 8.3+, Symfony 7.4 or 8.0, MIT. The Flex recipe is still pending in recipes-contrib, so for now the config files are created by hand (see README). GitHub: Stefan39/GuardedChatBundle Feedback welcome, especially on what you would expect from a guardrail layer like this.

    2 points
  5. Super excited about this new Symfony 8.2 feature! Controller attributes like `#[IsCsrfTokenValid]` and `#[RateLimit]` can now pass failures to your controller instead of throwing exceptions. Convenient attributes AND user-friendly feedback! symfony#67563

    1 like
  6. Super excited about this new Symfony 8.2 feature! Controller attributes like `#[IsCsrfTokenValid]` and `#[RateLimit]` can now pass failures to your controller instead of throwing exceptions. Convenient attributes AND user-friendly feedback! symfony#67563

    3 likes · 1 repost · 107 views
  7. UX Icons JS : les icônes de Symfony UX, enfin en JavaScript, React et Vue Si vous avez un projet Symfony récent, il y a de bonnes chances que vos icônes passent par Symfony UX Icons. Et c'est vrai que c'est agréable : une ligne de Twig, n'importe laquelle des plus de 200 000 icônes d'Iconify, un ux:icons:lock avant de déployer, et c'est réglé. {{ ux_icon('lucide:trash-2', { class: 'text-danger', 'aria-label': 'Supprimer' }) }} Tout va bien. Jusqu'au jour où cette même poubelle doit apparaître dans un contrôleur Stimulus. Ou dans le composant React de la page d'à côté. Là, Twig ne peut plus rien pour vous, et on finit tous par écrire ce genre de joyeuseté : // assets/controllers/delete_controller.js const TRASH_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 11v6m4-6v6m5-11v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>` this.buttonTarget.innerHTML = TRASH_ICON Un SVG copié-collé depuis le navigateur, des attributs recopiés à la main depuis ux_icons.yaml (ou pas), et un innerHTML qui fera tiquer votre Content Security Policy. Côté React, c'est lucide-react qui s'invite dans le package.json : une deuxième source d'icônes, avec ses propres versions et ses propres attributs par défaut. Et le jour où vous changez default_icon_attributes, vos icônes Twig changent. Pas les autres. Le fameux "je synchroniserai plus tard". L'histoire d'une PR refusée Mon premier réflexe a été de proposer la fonctionnalité directement à Symfony UX : symfony/ux#4072. L'idée : sur le warmup du cache, UX Icons exporte les icônes locales dans un module JavaScript, avec les attributs que ux_icon() leur donne. Un petit package npm les affiche ensuite en JavaScript, React et Vue. La PR a été fermée. Et pour être honnête, les arguments se tiennent : les bundlers savent déjà importer des .svg (avec SVGR ou vite-svg-loader), et l'ajout de React et Vue en dépendances de toutes les applications qui utilisent UX Icons, ce n'était pas un bon deal. 🤓 « _Mais alors, SVGR fait déjà le job, non ?_ » Pas tout à fait. SVGR n'a pas vu de release depuis août 2023, et vite-svg-loader ne connaît que Vite et Vue. Surtout, aucun des deux ne couvre le JavaScript vanilla d'un contrôleur Stimulus. Et aucun ne lit votre config ux_icons : vos icônes JS n'auront jamais les mêmes attributs que vos icônes Twig. Le code était écrit, testé, et il me servait. Je l'ai donc sorti en bundle à part, qui se branche sur un UX Icons non modifié. Open source, évidemment : je vous présente UX Icons JS Bundle. Et React et Vue y sont des dépendances optionnelles : vous n'installez que ce dont vous vous servez. Une icône, trois façons de l'afficher Le bundle dumpe vos icônes dans var/icons/index.js, un export nommé par icône. Vous importez celle dont vous avez besoin, comme n'importe quel module : // En JavaScript vanilla, par exemple dans un contrôleur Stimulus import { createIconElement } from '@silarhi/ux-icons-js' import { LucideTrash2Icon } from '../../var/icons/index.js' this.buttonTarget.append(createIconElement(LucideTrash2Icon, { class: 'text-danger', 'aria-label': 'Supprimer' })) // En React (ou '@silarhi/ux-icons-js/vue' pour Vue) import { UXIcon } from '@silarhi/ux-icons-js/react' import { LucideTrash2Icon } from '../var/icons/index.js' <UXIcon icon={LucideTrash2Icon} className="text-danger" aria-label="Supprimer" /> Et voilà ce que vous obtenez : exactement le HTML de ux_icon(). Pas "à peu près le même". Le même, caractère pour caractère, avec vos default_icon_attributes, les icon_attributes de vos icon sets, vos alias et vos suffixes. Le tout sans un seul innerHTML. La magnifique démo de cet article : 👉 <labs.silarhi.fr> En résumé, avant / après : | | Sans UX Icons JS | Avec UX Icons JS | |---|---|---| | Source des icônes | SVG copiés-collés, ou une lib par framework | Vos icônes UX Icons, et rien d'autre | | Attributs | Recopiés à la main depuis ux_icons.yaml | Lus depuis la config, comme ux_icon() | | JavaScript vanilla | innerHTML et une chaîne de caractères | createIconElement(), sans parser de HTML | | React / Vue | lucide-react, @heroicons/vue… | Un composant UXIcon | | Taille du bundle | Ça dépend de la lib | Seules les icônes importées | | Nom d'icône venant d'une API | Tout embarquer, ou un switch géant | loadIcon() : un chunk par icône | | Nouvelle icône | Télécharger, copier, coller | Écrire l'import, ux:icons:lock | C'est parti Deux packages, publiés depuis le même tag git : le bundle Symfony qui dumpe les icônes, et le package npm qui les affiche. composer require silarhi/ux-icons-js-bundle pnpm add @silarhi/ux-icons-js Si Flex ne l'a pas fait pour vous, enregistrez le bundle après UXIconsBundle, dont il réutilise la configuration : // config/bundles.php return [ // ... Symfony\UX\Icons\UXIconsBundle::class => ['all' => true], Silarhi\UxIconsJsBundle\SilarhiUxIconsJsBundle::class => ['all' => true], ]; Et c'est tout pour la configuration. Les icônes sont dumpées au prochain warmup du cache, ou à la main pour voir ce qui se passe : $ php bin/console ux-icons-js:dump -v * lucide:trash-2 * user [OK] Dumped 2 icon(s). Le dossier var/icons/ est généré : il n'a rien à faire dans git. Si var/ est déjà dans votre .gitignore (c'est le cas par défaut), vous n'avez rien à faire. Avec AssetMapper, le bundle mappe le dossier tout seul, il ne reste qu'à lui donner un nom dans importmap.php. Tout est détaillé dans la doc. Le plus dur est fait ! TOP. Les détails qui m'ont donné du fil à retordre Comme souvent, le gros du travail n'est pas là où on l'attend. Le même HTML, vraiment Promettre "le même HTML que ux_icon()", c'est facile. Le tenir, c'est une autre histoire. Les attributs passés à ux_icon() suivent les règles de la fonction html_attr() de Twig, et elles ont leurs subtilités : renderIcon(LucideHeartIcon) // … aria-hidden="true"> renderIcon(LucideHeartIcon, { 'aria-label': 'Favori' }) // … aria-label="Favori"> renderIcon(LucideHeartIcon, { class: ['btn-icon', isActive && 'active'] }) // … class="btn-icon" si isActive est false renderIcon(LucideHeartIcon, { height: null }) // plus de height du tout renderIcon(LucideHeartIcon, { 'data-config': { id: 1 } }) // … data-config="{&quot;id&quot;:1}"> aria-hidden="true" est ajouté sauf si l'icône a une alternative textuelle, null supprime un attribut (même configuré), les tableaux deviennent des listes de classes, les objets des data-* en JSON… Le côté JavaScript reproduit tout ça, jusqu'à la position des attributs : un attribut redéfini garde sa place dans la balise <svg>, comme en PHP. C'est d'ailleurs un bug que j'avais laissé passer dans la PR, et que le bundle corrige. Pour être sûr de ne pas se raconter d'histoires, les tests comparent la sortie de renderIcon() à celle de ux_icon(), octet par octet. La démo le fait aussi, en direct dans votre navigateur. Un export par icône, pour le tree-shaking Chaque icône est un export nommé : le nom de l'icône en PascalCase, suivi de Icon. Votre bundler ne garde que celles que vous importez, et les autres restent au placard. | Icône | Export | |---|---| | user | UserIcon | | lucide:trash-2 | LucideTrash2Icon | | fa7-brands:x-twitter | Fa7BrandsXTwitterIcon | | 500 | _500Icon | Petite subtilité : deux icônes peuvent donner le même nom d'export (sub:check et un alias sub-check donnent tous les deux SubCheckIcon). Plutôt que d'en écraser une en silence, le dump échoue et vous donne les deux noms. Une icône qui change toute seule en prod, c'est le genre de bug qu'on met une après-midi à comprendre. Pour TypeScript, un index.d.ts est dumpé à côté, avec le nom littéral de chaque icône. Et quand le nom de l'icône vient d'une API ? C'est LE cas qui m'a fait sortir une version 0.2.0 dans la foulée. Un import nommé, c'est très bien quand vous connaissez l'icône en écrivant le code. Mais quand votre API renvoie { "icon": "lucide:server" }, vous ne la connaissez qu'au runtime. Et embarquer toutes les icônes "au cas où", c'est exactement ce qu'on voulait éviter. Le bundle dumpe donc aussi un loaders.js, qui associe chaque nom d'icône au module de cette icône seule : import { createIconElement, loadIcon } from '@silarhi/ux-icons-js' import { loaders } from '../var/icons/loaders.js' const data = await (await fetch('/api/status')).json() const icon = await loadIcon(loaders, data.icon) // null pour un nom inconnu if (icon) { badge.append(createIconElement(icon, { class: 'text-success' })) } Votre bundler découpe chaque icône dans son propre chunk : importer loaders ne télécharge aucune icône, et loadIcon() ne télécharge que celle demandée. Une seule fois, même si dix composants la demandent en même temps. En React et Vue, c'est le composant UXLazyIcon, avec un fallback pendant le chargement : import { UXLazyIcon } from '@silarhi/ux-icons-js/react' import { loaders } from '../var/icons/loaders.js' <UXLazyIcon name={service.icon} loaders={loaders} fallback={<span className="placeholder" />} /> Et le loaders.d.ts exporte l'union des noms dumpés, IconName, pour typer vos réponses d'API ou vos props : une faute de frappe dans un nom d'icône ne passe plus la compilation. ux:icons:lock, côté JavaScript Avec UX Icons, le workflow classique est d'écrire ux_icon('lucide:mail') dans un template, puis de lancer ux:icons:lock : la commande trouve l'icône dans vos templates et la télécharge depuis Iconify. Je voulais exactement la même chose en JavaScript : // assets/controllers/mail_controller.js import { LucideMailIcon } from '../../var/icons/index.js' $ php bin/console ux:icons:lock ✓ Imported lucide:mail (License: ISC). Render with: {{ ux_icon('lucide:mail') }} JavaScript: import { LucideMailIcon } // Dumped 3 icon(s) for JavaScript. Vous écrivez l'import d'une icône qui n'existe pas encore, vous lancez la commande, et elle existe. Le bundle scanne vos fichiers .js, .jsx, .ts, .tsx, .vue et .svelte à la recherche des imports nommés depuis un module dont le chemin contient icons, et retrouve le nom de l'icône depuis le nom de l'export. Pas toujours une seule : LucideTrash2Icon peut venir de lucide:trash-2 ou de lucide:trash2. Tous les candidats sont proposés à ux:icons:lock, qui ignore ceux qu'Iconify ne connaît pas. PHP 8.2 et Symfony 6.4 La PR visait la dernière version de UX Icons, donc PHP 8.4. Le bundle, lui, descend jusqu'à PHP 8.2, Symfony 6.4 et symfony/ux-icons 2.36.1 : parce que dans la vraie vie, les projets ne sont pas tous à jour (les miens non plus 🤫). Il y a juste deux limites sur UX Icons 2.x, ux:icons:lock n'y scanne pas le JavaScript, et les booléens n'y sont pas rendus pareil. Tout est dans les notes de compatibilité. Ça donne quoi au final ? La démo de cet article : 👉 <labs.silarhi.fr> Vous y trouverez la même icône rendue par Twig, par JavaScript et par React (avec la comparaison octet par octet), un bouton "copier" en Stimulus qui change d'icône sans innerHTML, une notation en étoiles en React, et un tableau de statut dont les icônes viennent d'une API : ouvrez l'onglet réseau, chaque icône arrive dans son propre petit chunk. Le code est sur silarhi/symfony-docker-ci, le projet qui me sert depuis des années pour les démos de ce blog. Pour les curieux, sous le capot : - PHP 8.2+, Symfony 6.4, 7.x et 8.x, symfony/ux-icons 2.36.1+ ou 3.5+ - React 18 / 19 et Vue 3 en dépendances optionnelles - Webpack Encore, Vite, AssetMapper : n'importe quel bundler fait l'affaire - Le corps du SVG est dumpé sous forme d'arbre, React et Vue le rendent en éléments : zéro dangerouslySetInnerHTML, zéro v-html - 100 % du code couvert par les tests, et chaque combinaison de versions testée par la CI Voilà ! Vos icônes n'ont plus d'excuse pour exister en deux exemplaires. N'hésitez pas à laisser un commentaire si vous l'utilisez, ou si quelque chose coince. Et si le bundle vous fait gagner du temps, une petite ⭐ sur GitHub fait toujours plaisir. Pour aller plus loin - La démo du tuto - La documentation du bundle - Le code source du bundle sur GitHub - Le bundle sur Packagist et le package sur npm - Le code source de la démo sur GitHub - La PR d'origine : symfony/ux#4072 - La documentation de Symfony UX Icons - PicassoBundle, le composant image qui manquait à Symfony

  8. A PSR-14 dispatcher does not promise Symfony's second argument Ahnii! If your constructor accepts Psr\EventDispatcher\EventDispatcherInterface, call dispatch() with one argument. Symfony's dispatcher supports an optional event name, but the PSR interface does not promise that parameter. A small Waaseyaa fix shows how an implementation-specific convenience can slip into code that declares a portable dependency. Read the interface you inject The PSR-14 dispatcher interface declares this method: public function dispatch(object $event); The event object is the only argument in that contract. A concrete dispatcher may offer more, but callers depending on the PSR interface cannot assume those additions exist. For example, this call relies on an extra parameter: $dispatcher->dispatch($event, $event::class); Symfony accepts an optional event name in that position. In its implementation, leaving the name out uses the event object's class. When the second argument is already that class name, it is redundant for Symfony's normal dispatch behavior. The portable call is shorter: $dispatcher->dispatch($event); This stays within the PSR interface. Listener selection still belongs to the dispatcher and its listener provider; PSR-14 does not prescribe a second naming argument. The Waaseyaa change In the linked revision, EntityRepository depends on the PSR dispatcher interface. Its persistence path dispatched an EntityPersistedEvent and also supplied EntityPersistedEvent::class as the second argument. The repair removed that argument. The messaging subscriber already registered its listener under the event class, so the call could use Symfony's default class-based name. The event stayed at the same point in the persistence transaction. Removing an argument did not move the listener to a later notification phase or change when the transaction commits. That distinction matters when reviewing a small compatibility fix: check both the call signature and the behavior surrounding the call. When you need a custom event name An application may intentionally use names that differ from the event class. In that case, make the requirement explicit. Symfony's event-dispatcher contract exposes the optional event-name parameter. Depending on that contract tells readers and static analysis which behavior your code requires. It also makes the Symfony dependency visible. Before changing a dispatch call, check: - The imported interface. PSR and Symfony both have an EventDispatcherInterface; the namespace matters. - The listener registration. Confirm whether listeners use the event class or a custom name. - The surrounding behavior. Preserve transaction boundaries, listener ordering and exception handling unless the change explicitly targets them. - The verification. Run static analysis and the focused dispatch tests. Successful execution with one concrete dispatcher does not establish compatibility with every implementation of the interface. For the broader event model, see the PSR-14 walkthrough. For a small dependency fix, start with the interface named in your constructor and make the call match it. Baamaapii

  9. Symfony 5.4.54 fixes three CVEs: bounded nesting in ExpressionLanguage, no more backtracking in HeaderUtils::split(), and bounded recursion in the Yaml inline lexer. Still on 5.4? Upgrade. github.com/symfony/symfony/releases/tag/v5.4.54 #Symfony #PHP

  10. Symfony 5.4.54 fixes three CVEs: bounded nesting in ExpressionLanguage, no more backtracking in HeaderUtils::split(), and bounded recursion in the Yaml inline lexer. Still on 5.4? Upgrade. github.com/symfony/symfony/releases/tag/v5.4.54 #Symfony #PHP

    17 views
Trending awards, every completed day and week
Gold
341st place finishes
Silver
742nd place finishes
Bronze
983rd place finishes
Awarded repositories
3Owned by symfony now

Repositories

Ordered by gold, then silver, then bronze.

  1. symfonyBest: 1st by PR authors, 58 PR authors337092
  2. symfony-docsBest: 1st by PR authors, 9 PR authors134
  3. symfony-marketingBest: 2nd by PR authors, 18 PR authors012

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.