<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: TomvdPeet</title>
    <description>The latest articles on DEV Community by TomvdPeet (@tomvdpeet).</description>
    <link>https://dev.to/tomvdpeet</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3448179%2Fc417795c-9c03-4e58-9a7a-104eb1026841.jpg</url>
      <title>DEV Community: TomvdPeet</title>
      <link>https://dev.to/tomvdpeet</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tomvdpeet"/>
    <language>en</language>
    <item>
      <title>From PHP to Xan to Rust: speeding up large CSV imports in Symfony</title>
      <dc:creator>TomvdPeet</dc:creator>
      <pubDate>Thu, 24 Sep 2026 13:25:17 +0000</pubDate>
      <link>https://dev.to/tomvdpeet/from-php-to-xan-to-rust-speeding-up-large-csv-imports-in-symfony-46oe</link>
      <guid>https://dev.to/tomvdpeet/from-php-to-xan-to-rust-speeding-up-large-csv-imports-in-symfony-46oe</guid>
      <description>&lt;p&gt;The goal of a price comparison website is obviously to compare prices across as many webshops as possible. So far Vindle has mainly integrated relatively smaller and more specialized webshops, offering one or a few product types, and each with no more than 40,000 total products.&lt;/p&gt;

&lt;p&gt;Recently, however, we started integrating with Bol.com (one of the largest Dutch webshops). This was very exciting since Bol is a much more general webshop covering almost all product types, thus allowing an extra price to be compared for almost all products. But with a more general and larger scale webshop you also get many, many more products—roughly three orders of magnitude more, in fact. Bol.com offers over 73 million products, spread across 26 gzipped CSV feeds totaling roughly 27 GB.&lt;/p&gt;

&lt;p&gt;As a price comparison website, there is something else that is very important: the prices shown need to be up to date. To achieve this, Vindle updates all product feeds once an hour, storing all the product feed entries in our database so that further operations can be done based on that stored data. Operations like updating the prices and availability of offers, or finding new offers for existing products.&lt;/p&gt;

&lt;p&gt;This is where our journey starts. As you can imagine, suddenly increasing the scale of one merchant integration by roughly three orders of magnitude is going to require some optimizations. Now, since the new Bol.com integration is the main culprit behind this increase, our optimization efforts will be focused here. During this process, we will hopefully also learn some things that will help us optimize future feeds if needed (spoiler: we will—plenty)&lt;/p&gt;

&lt;p&gt;Before we even get into parsing the CSV files there is an optimization we can already make, and one we made at the start. The indisputably cheapest and fastest way to parse CSVs is to &lt;em&gt;not&lt;/em&gt; parse them at all. One nice thing Bol.com does, as I mentioned before, is split the 73 million products into 26 gzipped CSV files that are about 27 GB in total. The sizes of the individual files, however, range from around 1.5 MB to 4.8 GB, so there is quite a large size difference between them. But this still gives us two nice advantages: we only need to process the files we need, and we can include them in our hourly loop one at a time instead of all at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we started: PHP was fine
&lt;/h2&gt;

&lt;p&gt;Vindle being a PHP/Symfony application, it made the most sense to just write the initial version in PHP.&lt;/p&gt;

&lt;p&gt;The original implementation was about as exciting as you would expect: relying on &lt;code&gt;fgetcsv()&lt;/code&gt;. A reasonably well-optimized standard PHP function for reading CSV.&lt;/p&gt;

&lt;p&gt;Our first implementation was roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;merchant feed
    ↓
download and store the file
    ↓
gzip decompression stream
    ↓
PHP CSV reader
    ↓
filtering
    ↓
normalization
    ↓
database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now normally in an article about optimizing a process of this kind, we would now take some time and talk about how obviously terrible this initial version was in hindsight. We would talk about how building something like this in PHP is super inefficient, the wasted I/O with all this disk writing and reading going on, and various other problems.&lt;/p&gt;

&lt;p&gt;But we will not do this. In fact, I'm quite happy with this as an initial version. And here is why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It was quick and easy to implement, so we got off the ground fast&lt;/li&gt;
&lt;li&gt;It did exactly what it had to, generating the right data and keeping filtering statistics&lt;/li&gt;
&lt;li&gt;There were no major speed problems at all when running the initial few files&lt;/li&gt;
&lt;li&gt;We did not fall for premature optimization&lt;/li&gt;
&lt;li&gt;The parts of this that we kept in PHP ended up remaining basically the same to this day&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So why then does this article exist?&lt;/p&gt;

&lt;p&gt;Because we needed to start parsing more and larger Bol.com files to make proper use of their wide product coverage. And this version simply didn't allow for that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shortcomings of PHP
&lt;/h2&gt;

&lt;p&gt;The initial PHP-only implementation had one primary bottleneck, and it might not be what you would initially expect: the filtering step.&lt;/p&gt;

&lt;p&gt;See, during the filtering step we get rid of the vast majority of products for one of the following reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;There is no valid price&lt;/li&gt;
&lt;li&gt;It does not ship to the Netherlands&lt;/li&gt;
&lt;li&gt;It is not in a new condition&lt;/li&gt;
&lt;li&gt;It has no or an invalid brand&lt;/li&gt;
&lt;li&gt;Or it does not fall under one of our configured allowed categories&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now this last one is the most important since it ends up filtering out most products. As I mentioned, the feed is split up in multiple files but each file contains multiple sub-categories. For example, the &lt;code&gt;digital-reading&lt;/code&gt; file contains both &lt;code&gt;digital books&lt;/code&gt; as well as &lt;code&gt;e-readers&lt;/code&gt;. But since Vindle does not currently offer &lt;code&gt;digital books&lt;/code&gt; we just get rid of all of them and only keep the explicitly allowed sub-categories, in this case &lt;code&gt;e-readers&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Because of all this filtering, depending on the file we keep anywhere from about 9% down to only 0.0023% of the products. The most extreme example is a file containing ~7.2 million products, of which we keep about ~170.&lt;/p&gt;

&lt;p&gt;Doing all this filtering and getting rid of the majority of the rows was quickly becoming expensive in PHP, in part because of the &lt;code&gt;fgetcsv()&lt;/code&gt; function. Fortunately, there was quite a clean seam in the application: we could move this simple pre-processing to an external program and keep the more integrated work in PHP.&lt;/p&gt;

&lt;h2&gt;
  
  
  First optimization: Xan
&lt;/h2&gt;

&lt;p&gt;Introducing Xan, a command-line CSV processing tool written in Rust. Xan was (almost) exactly the tool that we needed for the job: it allows for basic conditional filtering and selecting specific columns. And it works directly with compressed CSVs.&lt;/p&gt;

&lt;p&gt;Symfony made it easy to keep the application logic where it was, while delegating the expensive pre-processing step to an external tool.&lt;/p&gt;

&lt;p&gt;Equipped with Xan our pipeline became roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;download and store the file
    ↓
Xan
├── filter unwanted rows
└── select required columns
    ↓ (stream)
PHP
    ↓
normalization
    ↓
database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This created a significant speed-up without introducing much complexity. We simply introduced the pre-filtering using Xan and then streaming those filtered rows directly into PHP.&lt;/p&gt;

&lt;p&gt;If you are reading this article because you're dealing with a comparable problem, I would highly recommend this sort of solution using Xan. This avoids having to write things in Rust and keeps everything quite contained.&lt;/p&gt;

&lt;p&gt;But our pre-processing had one additional requirement. We don't only want the filtered rows. We also keep statistics about why rows were filtered.&lt;/p&gt;

&lt;p&gt;For example, after processing a feed it is useful to know how many source rows were rejected because they had no valid offer, because they were not deliverable, because the brand was missing, or because they did not belong to a relevant product group.&lt;/p&gt;

&lt;p&gt;Sadly, this means that this relatively simple solution using Xan was not enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden cost of multiple passes
&lt;/h2&gt;

&lt;p&gt;Now how does one solve this issue? It's simple, just use more Xan.&lt;/p&gt;

&lt;p&gt;So, to circumvent this problem of not being able to retrieve the filtering statistics from Xan directly, we implemented an extra pre-pass that resolves them separately.&lt;/p&gt;

&lt;p&gt;Simplified, the flow was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;feed
 ├── Xan pass 1 → exclusion statistics
 └── Xan pass 2 → filtered rows → PHP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This worked, but it also felt like we took one step forward and one step backward, since we were now running Xan twice per feed. Though this was still considerably faster than using plain PHP, this was not going to allow us to comfortably go through all the files we'd need to every hour. This extra pass cost us roughly 20–40 seconds per feed.&lt;/p&gt;

&lt;p&gt;From here, a new idea was born: run both passes in parallel. The idea was simple: we centrally read and decompress the feed and then use &lt;code&gt;tee&lt;/code&gt; to pipe the result into our two separate Xan processes.&lt;/p&gt;

&lt;p&gt;Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;               ┌→ Xan statistics
gzip → tee ────┤
               └→ Xan filter → PHP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main improvements here were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;removing one disk read cycle&lt;/li&gt;
&lt;li&gt;removing the need to decompress twice&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Parallelization doesn't remove work
&lt;/h2&gt;

&lt;p&gt;This idea looked attractive on paper, but in practice it introduced a new problem: backpressure.&lt;/p&gt;

&lt;p&gt;Our two Xan branches did not do equal work. The statistics branch only had to count results, while the filtering branch also had to filter rows, stream them into PHP, and wait for PHP to continue processing them. Because Unix pipes have bounded buffers, the slower branch would eventually fill its pipe. Once that happened, &lt;code&gt;tee&lt;/code&gt; blocked, which blocked the decompressor, which in turn blocked the upstream producer.&lt;/p&gt;

&lt;p&gt;So while the pipeline looked more parallel, its throughput was still determined by the slowest consumer.&lt;/p&gt;

&lt;p&gt;On top of that, both branches still had to fully parse the CSV. So although the pipeline removed one disk read and one decompression pass, it still duplicated the expensive row-by-row parsing work.&lt;/p&gt;

&lt;p&gt;There was also a bonus problem: implementing clean error handling for this type of split process is much more complicated.&lt;/p&gt;

&lt;p&gt;So our supposedly more efficient streaming pipeline now had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;two CSV parser processes;&lt;/li&gt;
&lt;li&gt;duplicated filtering work;&lt;/li&gt;
&lt;li&gt;additional pipes;&lt;/li&gt;
&lt;li&gt;difficult error handling;&lt;/li&gt;
&lt;li&gt;more process scheduling;&lt;/li&gt;
&lt;li&gt;more context switching;&lt;/li&gt;
&lt;li&gt;and a slowest-consumer bottleneck.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And if all of that was not enough, the performance was also lacking. In general, this implementation was not faster than the serial one. In fact it leaned more toward being a few percent slower, getting worse with larger feeds.&lt;/p&gt;

&lt;p&gt;So at this point we realized that this type of pipe contraption would probably not get us to where we wanted. What we needed was much simpler.&lt;/p&gt;

&lt;h2&gt;
  
  
  One row, one decision
&lt;/h2&gt;

&lt;p&gt;For each row, we already knew exactly what needed to happen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read row
    ↓
check exclusion rules
    ↓
if excluded:
    update statistics
otherwise:
    write selected columns
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The statistics and filtering were not fundamentally incompatible operations. In fact, initially in PHP they were done in the same pass; we were just forced to separate them by the limitations of command-line tools.&lt;/p&gt;

&lt;p&gt;Basically, what we needed was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An external program more optimized for these types of operations than PHP&lt;/li&gt;
&lt;li&gt;The ability to collect statistics and filter at the same time so we only had to parse each CSV row once&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is when we started to experiment with Rust. In fairness, we considered Rust as an option from the very beginning but we wanted to see what was possible with off-the-shelf tools. Adding Rust to a project like this is not without its own downsides.&lt;/p&gt;

&lt;p&gt;The goal here was now very narrow: write this hot loop of filtering and collecting statistics into a small native Rust program. That's all. The only other requirement was that it must allow streaming of input and output data through &lt;code&gt;STDIN&lt;/code&gt; and &lt;code&gt;STDOUT&lt;/code&gt;. Lastly, it must be as dumb as possible, depending on as little context and business logic as we can make it.&lt;/p&gt;

&lt;p&gt;Streaming here is also of particular importance, since at this stage we'd made another realization. The current implementation had a non-deliberate dependency: empty disk space. When dealing with a 5 GB file our current implementation would have to first download that whole file to disk, so the machine doing this process would need 5+ GB of free disk space. Another thing is the fact that writing 5 GB to disk to then read it once and delete it also seemed wasteful. For these reasons we wanted the next iterations to directly stream the incoming download into our preprocessor instead of downloading the entire file to disk. We wanted to make the process diskless.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring Rust program
&lt;/h2&gt;

&lt;p&gt;There are a few reasons we chose Rust here, as opposed to another low-level language. But one of the reasons is that Xan is written in Rust and uses a well-optimized CSV library called &lt;code&gt;simd-csv&lt;/code&gt;. In fact, &lt;code&gt;simd-csv&lt;/code&gt; was designed around Xan's requirements, so we can use it as our foundation and build our relatively simple filtering and statistics collection on top of it.&lt;/p&gt;

&lt;p&gt;Its original contract was essentially:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stdin
  ↓
Rust
├── parse CSV
├── apply source filters
├── count exclusions
└── project required columns
  ↓
stdout: filtered CSV

stderr: statistics
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The existing PHP ingestion logic could continue consuming the resulting CSV exactly as it did before. There, it could perform the operations that are more integrated with our business logic. That compatibility was very useful: we were replacing one expensive stage of the pipeline rather than redesigning everything around it.&lt;/p&gt;

&lt;p&gt;The processing loop itself is super simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;each&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="py"&gt;.total_rows&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;exclusion_reason&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="nf"&gt;.exclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="py"&gt;.retained_rows&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;write_projected_record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yet this achieved the same thing as two passes through Xan and a bunch of piping, at a faster rate. In other words, we had roughly regained the speed of the original single-pass Xan setup, while also getting the filtering statistics that had previously required an additional pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symfony remained in charge
&lt;/h2&gt;

&lt;p&gt;What I like about this architecture is that Symfony never stopped being the core of the application.&lt;/p&gt;

&lt;p&gt;Rust only took over the narrow part of the pipeline that consisted of reading a lot of data and performing cheap, deterministic checks. Everything that depends on application context still lives in Symfony: managing and downloading feeds, credentials, domain logic, normalization, persistence and the rest of the actual ingestion workflow.&lt;/p&gt;

&lt;p&gt;That boundary ended up being quite useful. We did not need to redesign the application around Rust; Symfony could simply orchestrate another specialized component, while the existing PHP code continued handling the parts it was already well suited for.&lt;/p&gt;

&lt;p&gt;We could have continued moving all of that functionality into Rust. This would probably have been even faster in the end, assuming we were not limited by internet speed. But we chose not to.&lt;/p&gt;

&lt;p&gt;Why? Because it was simply not worth it. The potential improvement would not have outweighed the extra work and maintenance debt this would have created, especially the latter.&lt;/p&gt;

&lt;p&gt;The Rust program was implemented as a simple CLI. We chose that over FFI because FFI would have been more work. In fact, running this as a separate operating-system process gave us isolation, and a wonderfully boring interface.&lt;/p&gt;

&lt;p&gt;There are some interesting complications around managing those streams safely from PHP, but they deserve an article of their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The next frontier
&lt;/h2&gt;

&lt;p&gt;After we got the Rust preprocessor up and running we ran into a new bottleneck.&lt;/p&gt;

&lt;p&gt;The setup with the Rust process was now roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;compressed stream
    ↓
gzip -dc
    ↓
Rust
    ↓
PHP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compared to the previous iterations, this was already a much cleaner architecture: no more downloading the whole file to disk, no more multiple passes and no more parallel processes.&lt;/p&gt;

&lt;p&gt;But now the processing of the CSV became fast enough to expose a new problem.&lt;/p&gt;

&lt;p&gt;Gzip. It was often consuming an entire CPU core while the other parts of this pipeline were using much less.&lt;/p&gt;

&lt;p&gt;In practice this meant the Rust code was waiting for more data and therefore even our PHP code was waiting. The external decompressing process was now determining how quickly data would arrive.&lt;/p&gt;

&lt;p&gt;This is one of those things that shows how performance optimization often goes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A is slow
↓
optimize A
↓
B is now slow
↓
optimize B
↓
C becomes visible
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So it's not that the decompression suddenly became slower, the opposite happened. The rest of the system was now fast enough that our current decompression method could not keep up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving decompression into Rust
&lt;/h2&gt;

&lt;p&gt;One nice thing about gzip decompression is that there are a ton of libraries for it. So now it was just about finding the best one for this job.&lt;/p&gt;

&lt;p&gt;I won't go into the benchmarking process here, but eventually we arrived at the Rust &lt;code&gt;flate2&lt;/code&gt; crate, and found that it performed substantially better for this workload. Something I only realized later is that Xan uses the same library for compressed input handling.&lt;/p&gt;

&lt;p&gt;We deliberately hadn't included gzip decompression in the first Rust implementation. &lt;code&gt;gzip -dc&lt;/code&gt; already worked, and until it started limiting throughput there was no reason to replace it. But at this point it made a lot of sense to also move the decompression to Rust, instead of a separate process.&lt;/p&gt;

&lt;p&gt;Internally, the interesting part is very small. The gzip decoder implements Rust's &lt;code&gt;Read&lt;/code&gt; interface, and our feed processor was already generic over anything implementing &lt;code&gt;Read&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That left us with the final hot path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Symfony/PHP
└── Bol.com
    ↓
compressed network stream
    ↓
Rust
├── gzip decompression
├── CSV parsing
├── filtering
├── statistics
└── column projection
    ↓
filtered CSV
    ↓
Symfony/PHP
├── validation
├── normalization
├── affiliate logic
└── persistence
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No feed-sized temporary source file is required for the normal ingestion path.&lt;/p&gt;

&lt;p&gt;No second CSV parsing pass is required for statistics.&lt;/p&gt;

&lt;p&gt;And PHP only receives the rows that survived the cheap source-level filtering.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in practice?
&lt;/h2&gt;

&lt;p&gt;The result of these optimizations has been a material improvement to our hourly ingestion cycle, ultimately allowing us to go through all the Bol.com feeds we need without causing any problems or delays.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Mobile feed – PHP → Xan&lt;/strong&gt; (1.73M rows)&lt;/td&gt;
&lt;td&gt;357.3 s&lt;/td&gt;
&lt;td&gt;17.7–18.5 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~95% less time / ~20× faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Computer feed – external gzip → Rust gzip&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;24.10 s&lt;/td&gt;
&lt;td&gt;9.48 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;60.7% less time / 2.54× faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Computer – full production snapshot&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;60.6 s&lt;/td&gt;
&lt;td&gt;39.2 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;35.3% faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Daily-care – full production snapshot&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;64.3 s&lt;/td&gt;
&lt;td&gt;53.6 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16.6% faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first two rows are controlled processing benchmarks, while the last two are production snapshot observations on live inputs.&lt;/p&gt;

&lt;p&gt;The main gains here are not in isolated microbenchmarks, but in the end-to-end ingestion process. Across representative feeds and production snapshots, the improvements ranged from meaningful to dramatic, and together they gave us enough headroom to process the Bol.com feeds we needed comfortably within our hourly update cycle.&lt;/p&gt;

&lt;p&gt;And there is another practical benefit, enabled in part by making this process diskless. The larger feeds, which previously were unattractive to support, are now much less problematic because we no longer have to worry about their disk footprint. And even though we still have to download and parse every byte of them, the speed improvements have enabled us to support them hourly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we learned from the process
&lt;/h2&gt;

&lt;p&gt;Should we start rewriting all CSV processing in Rust? No.&lt;/p&gt;

&lt;p&gt;In fact, I think that for most Symfony developers, moving processing to an external system or writing a process in another language for a speed-up should not be done lightly, and definitely not straight away. It also creates its own overhead.&lt;/p&gt;

&lt;p&gt;The progression is the main takeaway here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PHP was the right implementation at the beginning, and large parts of that code still remain&lt;/li&gt;
&lt;li&gt;Xan was the right move when we realized pre-processing was becoming expensive&lt;/li&gt;
&lt;li&gt;Rust became attractive when our processing requirements became sufficiently application-specific that general-purpose tools were not effectively able to do what we needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I think this progression also maps quite well to a rough decision tree for dealing with performance problems in PHP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PHP performance problem
        ↓
Isolate the hot path
        ↓
Can PHP handle it with reasonable optimization?
      ↓                         ↓
    yes                         no
    ↓                           ↓
Stay in PHP             Is there a good external tool?
                              ↓              ↓
                            yes             no
                            ↓               ↓
                     Use the tool      Write a targeted low-level component
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Removing disk I/O does not automatically make a pipeline faster
&lt;/h3&gt;

&lt;p&gt;Streaming in this process had some important benefits, mainly that we no longer needed to fully download these large files. But if the machine is CPU-bound, replacing disk access with more simultaneous CPU work can actually make throughput worse.&lt;/p&gt;

&lt;p&gt;Measure the pipeline rather than assuming that “streaming” or “parallel” means “faster.”&lt;/p&gt;

&lt;h3&gt;
  
  
  Parallel work is still work
&lt;/h3&gt;

&lt;p&gt;Our &lt;code&gt;tee&lt;/code&gt; experiment looked elegant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;one decompression
two parallel consumers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But both consumers still had to parse every row, parallelizing duplicate work doesn't magically make it less expensive. On a small VPS with limited CPU capacity, that distinction matters a lot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backpressure is useful
&lt;/h3&gt;

&lt;p&gt;In a streaming architecture, a blocking or waiting step can seem like something is going wrong. But in reality it is what makes the architecture work, by keeping it bounded. If any part of the chain slows down, the section before it just waits once its buffer fills up. Memory usage remains controlled because each stage naturally slows the stage before it when needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Optimize the operation, not the language
&lt;/h3&gt;

&lt;p&gt;The biggest structural improvement came from turning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;parse for statistics
+
parse for filtering
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;into:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;parse once
→ statistics + filtering
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rust made implementing that efficient loop straightforward, but changing the algorithm mattered just as much as changing the runtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  The best language boundary can be a pipe
&lt;/h3&gt;

&lt;p&gt;We initially considered whether integrating Rust with PHP should be done through FFI. However, for this workload and type of problem that would have added unnecessary complexity. Since we're not calling a tiny Rust function thousands of times, we have a single long-running native process parsing a large stream of data.&lt;/p&gt;

&lt;p&gt;Because of this, running as a subprocess gives us a clean boundary, natural streaming, crash isolation and almost no coupling between the two ecosystems.&lt;/p&gt;

&lt;p&gt;Sometimes &lt;code&gt;stdin&lt;/code&gt;, &lt;code&gt;stdout&lt;/code&gt; and an exit code are all the integration layer you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it leaves us
&lt;/h2&gt;

&lt;p&gt;The final architecture is more complicated than the original implementation in PHP.&lt;/p&gt;

&lt;p&gt;But the complexity is narrowly contained.&lt;/p&gt;

&lt;p&gt;Symfony still behaves like a Symfony application.&lt;/p&gt;

&lt;p&gt;Rust behaves like a fast Unix-style preprocessing tool.&lt;/p&gt;

&lt;p&gt;And the interface between them consists primarily of streams.&lt;/p&gt;

&lt;p&gt;The result is a feed ingestion pipeline that can deal much more comfortably with the scale we're starting to encounter, without requiring the rest of the application to care how those source rows were reduced.&lt;/p&gt;

&lt;p&gt;And multiple new possibilities have opened up to us. The Rust component could eventually become a reusable core for other heavy data-processing operations. Other feeds could move onto the same preprocessing infrastructure if it's shown that it is worthwhile. And we have the option to eventually implement FFI functions if they're justified.&lt;/p&gt;

&lt;p&gt;But for now, the most useful result is also the simplest one:&lt;/p&gt;

&lt;p&gt;We found one expensive loop in a Symfony application, moved exactly that loop somewhere better suited to running it, and left everything else alone.&lt;/p&gt;

&lt;p&gt;Originally published on &lt;a href="https://vindle.nl/insights/from-php-to-xan-to-rust-speeding-up-large-csv-imports-in-symfony" rel="noopener noreferrer"&gt;Vindle&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>symfony</category>
      <category>rust</category>
      <category>programming</category>
    </item>
    <item>
      <title>Getting Agent-Ready with Symfony</title>
      <dc:creator>TomvdPeet</dc:creator>
      <pubDate>Thu, 28 May 2026 13:26:23 +0000</pubDate>
      <link>https://dev.to/tomvdpeet/getting-agent-ready-with-symfony-1a35</link>
      <guid>https://dev.to/tomvdpeet/getting-agent-ready-with-symfony-1a35</guid>
      <description>&lt;p&gt;The ongoing large scale adoption of AI is rapidly changing the way many people interact with the web. Therefore websites and web-apps must adapt so they can continue to thrive. In this article i will go over some improvements you can make to your Symfony application to start making it &lt;em&gt;agent-ready&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Since i want to cover multiple things in this article i will not be going into great detail on each of them, so consider this article more as an introduction to agent-ready-ness and these concepts. I did however  write a &lt;a href="https://packagist.org/packages/tomvdpeet/markdown-negotiation-bundle" rel="noopener noreferrer"&gt;small bundle&lt;/a&gt; for one of the concepts and for the last one I wrote a basic implementation prompt which can be found in the last chapter.&lt;/p&gt;

&lt;p&gt;A great resource that will likely help you on this journey and going forward is &lt;a href="https://isitagentready.com/" rel="noopener noreferrer"&gt;isitagentready.com&lt;/a&gt;. This tool gives a good overview of the improvements you can make to your site and is in part what this article is based on.&lt;/p&gt;

&lt;h2&gt;
  
  
  SEO Fundamentals
&lt;/h2&gt;

&lt;p&gt;Before getting into any agent specific improvements, it is best we first glance over some SEO fundamentals.&lt;/p&gt;

&lt;p&gt;The reason for this is that most things we consider SEO improvements are generally machine- readability and discoverability improvements, and it just so happens that agents are machines too. That is why i would suggest having your SEO fundamentals on point first, since they will not only help with SEO but they're a nice baseline from which to start getting agent-ready&lt;/p&gt;

&lt;p&gt;Now, there's a good chance you already have (most) these in place but if you don't i'd highly recommend you look them first before focusing on agents. I will not go into detail for these, if you do want more information on them you can find plenty online, or ask your agent.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Security&lt;/strong&gt; make sure your site is secure and served over https&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pages must be crawlable&lt;/strong&gt; requests should return the correct status codes and not be &lt;code&gt;noindex&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;robots.txt&lt;/code&gt;&lt;/strong&gt; You should have this setup including the sitemap direcive&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sitemap&lt;/strong&gt; A proper XML sitemap that contains all the links you want to be crawled / indexed. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inform indexers&lt;/strong&gt; Setup google search console and Bing webmaster tools and submit your sitemap. Also check these tools occasionally for any indexing warnings&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canonical URLs&lt;/strong&gt; make sure each page has a specified canonical URL using &lt;code&gt;rel="canonical"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proper titles and descriptions&lt;/strong&gt; Make sure each page has proper title's and descriptions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Good semantic HTML&lt;/strong&gt; using elements like: &lt;code&gt;nav&lt;/code&gt;, &lt;code&gt;main&lt;/code&gt;, &lt;code&gt;footer&lt;/code&gt;,&lt;code&gt;h1&lt;/code&gt; etc. Where appropriate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured data&lt;/strong&gt; implementing structured data where it fits things like: &lt;code&gt;BreadcrumbList&lt;/code&gt;, &lt;code&gt;Product&lt;/code&gt;, &lt;code&gt;FAQPage&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast, mobile-friendly and accessible&lt;/strong&gt; your site should be fast, accessible and work on all devices.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Content Signals
&lt;/h2&gt;

&lt;p&gt;Starting with content signals this is the simplest improvement you can make. &lt;code&gt;content-signal&lt;/code&gt; is a proposed &lt;code&gt;robots.txt&lt;/code&gt; directive, meant to communicate what you permit AI to do with content from your site. A simple example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User-Agent: *
Content-Signal: ai-train=no, search=yes, ai-input=yes
Allow: /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each of the categories mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ai-train&lt;/code&gt; Your content may be used for training or fine-tuning AI models&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;search&lt;/code&gt; Your content may be used for building a search index and providing search results. This is more so related to traditional search indexes &lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ai-input&lt;/code&gt; Your content may be used as input for AI models. This mainly means things like AI web search tools and does &lt;strong&gt;not&lt;/strong&gt; include training&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Generally you'll want &lt;code&gt;search&lt;/code&gt; and  &lt;code&gt;ai-input&lt;/code&gt; to be &lt;code&gt;yes&lt;/code&gt; for the purpose of indexing in search and for use by AI through web search tools. I would consider these two standard for most websites, even if you don't want AI to train on your content.  &lt;/p&gt;

&lt;p&gt;&lt;code&gt;ai-train&lt;/code&gt; is more personal and project dependent. The obvious con is that AI may copy your idea's, content or style. But in the case your site is more commercial and features your brand name in the text having AI train on it could be a positive.&lt;/p&gt;

&lt;p&gt;It is also possible to apply specific content signals for specific URLs, more information on this is best found on &lt;a href="https://contentsignals.org/" rel="noopener noreferrer"&gt;contentsignals.org&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Markdown Negotiation
&lt;/h2&gt;

&lt;p&gt;Markdown negotiation is the ability for agents to request your pages as &lt;code&gt;text/markdown&lt;/code&gt; instead of HTML, using the &lt;code&gt;Accept: text/markdown&lt;/code&gt; request header. &lt;/p&gt;

&lt;p&gt;The reason we'd want to implement this is that agents &lt;em&gt;speak&lt;/em&gt; Markdown very well, far beter than HTML and it is also much more token efficient. In a lot of cases the HTML you would otherwise return gets turned into Markdown anyway before an AI sees it. Therefore if you create the Markdown yourself you have a lot more control over it. &lt;/p&gt;

&lt;p&gt;There are two general ways to implement this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Explicit markdown responses:&lt;/strong&gt; checking in each route if the request prefers markdown and returning an appropriate response. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Centralized HTML-to-Markdown conversion:&lt;/strong&gt; turning the HTML returned by routes into Markdown centrally.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These two complement each other well, generally for the most control you'll want explicit Markdown responses and then fall back to passive HTML-to-Markdown conversion for pages that are less important.&lt;/p&gt;

&lt;p&gt;Initially in this article i was going to give a high level overview of how to implement both of these options. Instead I decided that this would be far better to just turn into a small bundle, so i present: &lt;a href="https://packagist.org/packages/tomvdpeet/markdown-negotiation-bundle" rel="noopener noreferrer"&gt;tomvdpeet/markdown-negotiation-bundle&lt;/a&gt;.  The bundle is mainly focused on passive HTML-to-markdown conversion, here is an example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Route('/docs', name: 'docs', options: ['markdown' =&amp;gt; true])]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'docs/index.html.twig'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This example will turn the rendered html in to clean Markdown when requested, simply by adding &lt;code&gt;options: ['markdown' =&amp;gt; true]&lt;/code&gt; to the &lt;code&gt;Route&lt;/code&gt; attribute. The resulting Markdown will also be stripped of stuff like &lt;code&gt;footer&lt;/code&gt;, &lt;code&gt;nav&lt;/code&gt;, &lt;code&gt;head&lt;/code&gt; and comparable layout/decorative HTML content.&lt;/p&gt;

&lt;p&gt;The bundle also has some other features like supporting a dev-only &lt;code&gt;?_markdown&lt;/code&gt; GET parameter that will handle the request as if it prefers Markdown for easier debugging. And some tools for explicitly specified Markdown responses as well. Though the main focus is a DX-friendly and minimal overhead implementation of the centralized conversion.&lt;/p&gt;

&lt;h2&gt;
  
  
  API Exposure
&lt;/h2&gt;

&lt;p&gt;The previous two changes we looked at were purely about machine- readability and discoverability, API Exposure on the other hand opens the door to &lt;em&gt;machine-usability&lt;/em&gt;. So before going into API Exposure itself I want to touch on the change in perspective this goes hand in hand with.&lt;/p&gt;

&lt;p&gt;Most websites, web-apps, platforms, etc. are user facing and support a user interface, UI. This makes sense since &lt;em&gt;humans&lt;/em&gt; interact with them, right now. However for our systems to be agent-ready, we will also need to make them agent-usable and thus &lt;em&gt;machine-usable&lt;/em&gt;. This change requires us to look at our systems increasingly more as an API based system rather then just a UI based system.&lt;/p&gt;

&lt;p&gt;I believe API Exposure is a nice entry-point into making a Symfony application more agent-ready and machine-usable, since it is the way an agents discover the capabilities and API's your application provides.&lt;/p&gt;

&lt;p&gt;API Exposure (RFC 9727) defines a standard URL, API catalog (&lt;code&gt;/.well-known/api-catalog&lt;/code&gt;) that lists the available API's and documentation for them in a standard JSON based format.&lt;/p&gt;

&lt;p&gt;Since i didn't want to add a full implementation tutorial in this article i did write a prompt to have an agent implement a basic setup of this for you. To help you get started with this the prompt will also setup OpenApi docs for your search page, if you have one. It does rely on the &lt;code&gt;nelmio/api-doc-bundle&lt;/code&gt; to do this.&lt;/p&gt;

&lt;p&gt;As mentioned API exposure is about making your API's and functionalities discoverable. I am not going into further detail on this because it it very application depended. But you should now have a stable and expandable foundation for making any API's and functionalities you add discoverable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The shift to a more agentic internet will mean that we need to shift the perception of our own applications to be more and more API like. Since all of this is still very new, standards and conventions are still uncertain and changing quickly. However i believe that what I've covered here is the right direction and what internet will be moving to sooner or later. So getting a head start on implementing or at least learning about these standards will help you and your application continue to thrive in the . &lt;/p&gt;

&lt;p&gt;original article: &lt;a href="https://vindle.nl/insights/getting-agent-ready-with-symfony" rel="noopener noreferrer"&gt;https://vindle.nl/insights/getting-agent-ready-with-symfony&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompts
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Api exposure prompt
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Make this Symfony app more agent-ready by adding RFC 8288 Link response headers, an RFC 9727 API catalog, and OpenAPI docs with NelmioApiDocBundle.

Use the smallest pragmatic change that fits the existing codebase. Read the existing routing/controller structure first and preserve current behavior.

Tasks:

1. Install and enable NelmioApiDocBundle
- If not already installed, run:
  composer require nelmio/api-doc-bundle
- Verify the bundle is registered in config/bundles.php.
- Add or update:
  config/packages/nelmio_api_doc.yaml
  config/routes/nelmio_api_doc.yaml

2. Expose OpenAPI docs
- Add JSON docs at:
  /api/doc.json
  using nelmio_api_doc.controller.swagger
- Add Swagger UI at:
  /api/doc
  using nelmio_api_doc.controller.swagger_ui
- Configure Nelmio metadata with the project name, description, version, and server URL.
- Keep operation details on controller route methods using OpenAPI attributes, not hard-coded YAML paths.

3. Document the search page if the project has one
- Find the existing public search route, for example /search or /zoeken.
- Add OpenAPI route-level attributes to that controller method:
  - tag: Search
  - query parameter: q, optional string
  - 200 response with text/html
  - if the app supports markdown content negotiation, also add text/markdown
- Update nelmio_api_doc.areas.path_patterns so the search route is included in generated docs, while excluding /api/doc itself.

Example attributes:

use OpenApi\Attributes as OA;

#[OA\Tag(name: 'Search')]
#[OA\Parameter(
    name: 'q',
    description: 'Search term.',
    in: 'query',
    required: false,
    schema: new OA\Schema(type: 'string'),
)]
#[OA\Response(
    response: 200,
    description: 'Search results page.',
    content: [
        new OA\MediaType(
            mediaType: 'text/html',
            schema: new OA\Schema(type: 'string'),
        ),
        new OA\MediaType(
            mediaType: 'text/markdown',
            schema: new OA\Schema(type: 'string'),
        ),
    ],
)]

4. Add an RFC 9727 API catalog
- Add a route:
  /.well-known/api-catalog
- Return application/linkset+json.
- Include links to:
  - the OpenAPI JSON document as service-desc
  - the Swagger UI as service-doc
  - the public search route as item/search-related discovery if available
- Add a Link response header on the catalog response:
  Link: &amp;lt;/.well-known/api-catalog&amp;gt;; rel="api-catalog"; type="application/linkset+json"

Example linkset shape:

{
  "linkset": [
    {
      "anchor": "https://example.com/.well-known/api-catalog",
      "item": [
        {
          "href": "https://example.com/search{?q}",
          "type": "text/html",
          "title": "Product search"
        }
      ],
      "service-desc": [
        {
          "href": "https://example.com/api/doc.json",
          "type": "application/vnd.oai.openapi+json",
          "title": "OpenAPI description"
        }
      ],
      "service-doc": [
        {
          "href": "https://example.com/api/doc",
          "type": "text/html",
          "title": "API documentation"
        }
      ]
    }
  ]
}

5. Add homepage Link discovery headers
- Add a Link response header to the homepage response.
- Prefer putting this directly in the homepage controller if it is homepage-only.
- Use one combined RFC 8288 Link header value.

Example:

Link: &amp;lt;/.well-known/api-catalog&amp;gt;; rel="api-catalog"; type="application/linkset+json", &amp;lt;/search&amp;gt;; rel="search"; type="text/html"; title="Site search", &amp;lt;/api/doc.json&amp;gt;; rel="service-desc"; type="application/vnd.oai.openapi+json"; title="OpenAPI description", &amp;lt;/api/doc&amp;gt;; rel="service-doc"; type="text/html"; title="API documentation"

6. Validate
Run the relevant checks for the project, at minimum:
- php bin/console lint:yaml config/packages/nelmio_api_doc.yaml config/routes/nelmio_api_doc.yaml
- php bin/console debug:router
- php bin/console lint:container
- php bin/console nelmio:apidoc:dump --format=json
- project tests, or targeted tests for the new API catalog/controller behavior

7. Documentation
- Update project docs to mention:
  - /api/doc.json
  - /api/doc
  - /.well-known/api-catalog
  - homepage Link headers
  - OpenAPI operation details live as route-level attributes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>symfony</category>
      <category>webdev</category>
      <category>ai</category>
      <category>programming</category>
    </item>
    <item>
      <title>Symfony as JWT provider</title>
      <dc:creator>TomvdPeet</dc:creator>
      <pubDate>Sat, 30 Aug 2025 12:11:48 +0000</pubDate>
      <link>https://dev.to/tomvdpeet/symfony-as-jwt-provider-28a4</link>
      <guid>https://dev.to/tomvdpeet/symfony-as-jwt-provider-28a4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;In the process of writing this guide Ryan Weaver passed away. &lt;br&gt;
This news was truly shocking to me and my thoughts are with his family and loved ones. I hope this guide can do some justice to his spirit. &lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So, in my &lt;a href="https://browsely.ai/blog/pivot-auth0-to-symfony" rel="noopener noreferrer"&gt;previous article&lt;/a&gt; I talked about why I decided to switch from Auth0 to Symfony as my authentication provider. In that article I also promised to write a follow up guide on how I actually implemented  it. &lt;/p&gt;

&lt;p&gt;For some context this guide is &lt;strong&gt;not&lt;/strong&gt; about authenticating &lt;strong&gt;to&lt;/strong&gt; &lt;strong&gt;Symfony&lt;/strong&gt; with JWT rather it is about using Symfony to authenticate users to other systems. The setup used in this guide is quite simple. &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The user interacts through the browser extension.&lt;/li&gt;
&lt;li&gt;These interactions (e.g. LLM streaming and such) go through Node.js. &lt;/li&gt;
&lt;li&gt;Symfony is setup as a back-end API for handling data. &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now Symfony also houses the user and authentication system and that flow goes as follows: &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The user logs into the Symfony application&lt;/li&gt;
&lt;li&gt;The extension requests a JWT and Refresh token (RT)&lt;/li&gt;
&lt;li&gt;The extension calls to Node.js with the JWT and Node.js validates it &lt;/li&gt;
&lt;li&gt;When the JWT expires the RT is exchanged for a new RT and JWT&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In this guide I will only go over the Symfony side of this system. so it will require you to implement the other areas yourself, however for most of these you can follow Auth0's guides since its basically the same set up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before we begin
&lt;/h2&gt;

&lt;p&gt;This is a bit more of an advanced guide and its quite loose in the sense I expect you to apply it to your situation, so blindly copy and pasting this code and commands will not solve your problem. &lt;/p&gt;

&lt;p&gt;I also presume you have / know the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An up and running symfony application with users and login&lt;/li&gt;
&lt;li&gt;Some knowledge of how JWT works and what it is&lt;/li&gt;
&lt;li&gt;Enough knowledge of security to apply this in a safe way&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Configuration
&lt;/h2&gt;

&lt;p&gt;To start something to mention is that we will be using  &lt;code&gt;web-token/jwt-bundle&lt;/code&gt;, all the classes in this bundle are in the &lt;code&gt;Jose\&lt;/code&gt; namespace, and i'm not sure why, I was also confused. I will however refer to this bundle as Jose since its shorter.&lt;/p&gt;

&lt;p&gt;To begin we must install the aforementioned bundle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require web-token/jwt-bundle web-token/jwt-signature-algorithm-rsa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This Jose bundle is a framework for working with JWT and its the foundation for this setup.&lt;/p&gt;

&lt;p&gt;Now that we have installed the bundle we will begin by configuring it, and to do this we must generate the keys. The keys will live in the &lt;code&gt;config/jwt/&lt;/code&gt; folder but you can change this if you want.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: you &lt;strong&gt;should not&lt;/strong&gt; commit these key's to git&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Run the following commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;config/jwt
php bin/console keyset:generate:rsa &lt;span class="nt"&gt;--random_id&lt;/span&gt; &lt;span class="nt"&gt;--use&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sig &lt;span class="nt"&gt;--alg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;RS256 3 4096 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; config/jwt/signature.jwkset
php bin/console key:generate:rsa 2048 &lt;span class="nt"&gt;--use&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sig &lt;span class="nt"&gt;--alg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;RS256 &lt;span class="nt"&gt;--random_id&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; config/jwt/signature.jwk
php bin/console keyset:rotate &lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;config/jwt/signature.jwkset&lt;span class="sb"&gt;`&lt;/span&gt; &lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;config/jwt/signature.jwk&lt;span class="sb"&gt;`&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; config/jwt/signature.jwkset
php bin/console keyset:convert:public &lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;config/jwt/signature.jwkset&lt;span class="sb"&gt;`&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; config/jwt/public.jwkset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Note:&lt;br&gt;
If you have read the docs, they use &lt;code&gt;./jose.phar&lt;/code&gt; however the commands should just start with &lt;code&gt;php bin/console&lt;/code&gt; this goes for all &lt;code&gt;jose&lt;/code&gt; commands. So no need to mess with &lt;code&gt;./jose.phar&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I will quickly go over what each of these commands does, to give some context. &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;creates the &lt;code&gt;config/jwt&lt;/code&gt; folder&lt;/li&gt;
&lt;li&gt;generates a private keyset of 3 keys (none of which we'll use)&lt;/li&gt;
&lt;li&gt;generates a single private key that we will use for signing our JWT's&lt;/li&gt;
&lt;li&gt;rotates our newly generated &lt;em&gt;real&lt;/em&gt; key into the key set &lt;/li&gt;
&lt;li&gt;creates a public keyset that other systems will use to verify our signed JWT's&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now one nice thing about this JWT setup is that we can change our private key without breaking everything. so if its compromised you simply run the last 3 commands and the whole system will use a new private key, while the existing signed JWT's are still valid since their public keys remain in the public key set.&lt;/p&gt;

&lt;p&gt;To finish off the configuration of Jose we must set up the config file if you followed the steps so far you can simply copy and paste it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /config/packages/jose.yaml&lt;/span&gt;
&lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  
    &lt;span class="na"&gt;env(JWT_PRIV)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%kernel.project_dir%/config/jwt/signature.jwk'&lt;/span&gt;  
    &lt;span class="na"&gt;env(JWTSET_PRIV)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%kernel.project_dir%/config/jwt/signature.jwkset'&lt;/span&gt;  
    &lt;span class="na"&gt;env(JWTSET_PUB)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%kernel.project_dir%/config/jwt/public.jwkset'&lt;/span&gt;  

&lt;span class="na"&gt;jose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  
    &lt;span class="na"&gt;keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  
        &lt;span class="na"&gt;user_sig_priv&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  
            &lt;span class="na"&gt;jwk&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  
                &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%env(file:JWT_PRIV)%'&lt;/span&gt;  
                &lt;span class="na"&gt;is_public&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;  

    &lt;span class="na"&gt;key_sets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  
        &lt;span class="na"&gt;user_sig_priv&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  
            &lt;span class="na"&gt;jwkset&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  
                &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%env(file:JWTSET_PRIV)%'&lt;/span&gt;  
                &lt;span class="na"&gt;is_public&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;  
        &lt;span class="na"&gt;user_sig_pub&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  
            &lt;span class="na"&gt;jwkset&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  
                &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%env(file:JWTSET_PUB)%'&lt;/span&gt;  
                &lt;span class="na"&gt;is_public&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;  

    &lt;span class="na"&gt;jws&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  
        &lt;span class="na"&gt;builders&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  
            &lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  
                &lt;span class="na"&gt;signature_algorithms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;RS256'&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;  
                &lt;span class="na"&gt;is_public&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  

        &lt;span class="na"&gt;verifiers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  
            &lt;span class="na"&gt;verifier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  
                &lt;span class="na"&gt;signature_algorithms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;RS256'&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;  
                &lt;span class="na"&gt;is_public&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All this config basically does is allow us to use the keys we just created more easily within our Symfony project. it also sets up a builder and a verifier service which we'll use later. &lt;br&gt;
After doing this the JWT icon should appear in the debug toolbar (on any page) and it will show 1 key and 2 keysets.&lt;/p&gt;
&lt;h2&gt;
  
  
  Refresh tokens
&lt;/h2&gt;

&lt;p&gt;Now this guide includes the use of refresh tokens (RT). These are tokens stored in the database that the user can exchange for a new JWT and RT. The reason we want this is so we can keep the TTL of the refresh token very short. which has various advantages (which I won't list), and as long as the RT doesn't expire or is removed from the db the user remains logged in.&lt;/p&gt;

&lt;p&gt;To store the RT in the database we obviously need to create an entity, here is a stub of the one I use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;    &lt;span class="c1"&gt;# src/Entity/RefreshToken.php&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Repository\RefreshTokenRepository&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doctrine\ORM\Mapping&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="no"&gt;ORM&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ORM\Entity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repositoryClass&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;RefreshTokenRepository&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;  
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RefreshToken&lt;/span&gt;  
&lt;span class="p"&gt;{&lt;/span&gt;  
    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ORM\Id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  
    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ORM\GeneratedValue&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  
    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ORM\Column&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  

    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ORM\Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;  
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  

    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ORM\Column&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="nc"&gt;\DateTime&lt;/span&gt; &lt;span class="nv"&gt;$expires&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; 

    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ORM\ManyToOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inversedBy&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'refreshTokens'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;  
    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ORM\JoinColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;  
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  

    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ORM\Column&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="nc"&gt;\DateTime&lt;/span&gt; &lt;span class="nv"&gt;$issued&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;//GETTERS AND SETTERS NOT INCLUDED&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want to you can change this entity, add whatever fields you'd like. You can change the &lt;code&gt;$user&lt;/code&gt; property to something else so that the token doesn't have to be associated with a &lt;code&gt;User&lt;/code&gt; object . this offers more options but also more complications. Also the &lt;code&gt;$issued&lt;/code&gt; property is technically not needed, but I like having it. &lt;/p&gt;

&lt;h2&gt;
  
  
  Generating JWT's
&lt;/h2&gt;

&lt;p&gt;Now with all that setup out of the way I introduce to you the &lt;code&gt;JWTService&lt;/code&gt;. This service will do all that you need for most JWT setups:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/Service/JWTService.php &lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Service&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Entity\RefreshToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Entity\User&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="no"&gt;Jose\Component\Core\JWK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Jose\Component\Core\JWKSet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="no"&gt;Jose\Component\Signature\JWS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Jose\Component\Signature\JWSBuilder&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Jose\Component\Signature\JWSVerifier&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Jose\Component\Signature\Serializer\CompactSerializer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;JWTService&lt;/span&gt;  
&lt;span class="p"&gt;{&lt;/span&gt;  
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;ALG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'RS256'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;JWSBuilder&lt;/span&gt; &lt;span class="nv"&gt;$builder&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;JWSVerifier&lt;/span&gt; &lt;span class="nv"&gt;$verifier&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;CompactSerializer&lt;/span&gt; &lt;span class="nv"&gt;$serializer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;  
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;JWK&lt;/span&gt;    &lt;span class="nv"&gt;$userSigPrivKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;JWKSet&lt;/span&gt; &lt;span class="nv"&gt;$userSigPubKeySet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  
        &lt;span class="kt"&gt;JWSBuilder&lt;/span&gt;              &lt;span class="nv"&gt;$builderJwsBuilder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  
        &lt;span class="kt"&gt;JWSVerifier&lt;/span&gt;             &lt;span class="nv"&gt;$verifierJwsVerifier&lt;/span&gt;  
    &lt;span class="p"&gt;)&lt;/span&gt;  
    &lt;span class="p"&gt;{&lt;/span&gt;  
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$builderJwsBuilder&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;verifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$verifierJwsVerifier&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;serializer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CompactSerializer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  
    &lt;span class="p"&gt;}&lt;/span&gt;  

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;generateJWS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;JWS&lt;/span&gt;  
    &lt;span class="p"&gt;{&lt;/span&gt;  
        &lt;span class="nv"&gt;$claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;  
            &lt;span class="s1"&gt;'sub'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEmail&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;  
            &lt;span class="s1"&gt;'data'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;  
                &lt;span class="c1"&gt;//This array is where you can store some signed information about the user &lt;/span&gt;
                &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEmail&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;  
            &lt;span class="p"&gt;],&lt;/span&gt;  
            &lt;span class="s1"&gt;'iat'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;  
            &lt;span class="s1"&gt;'exp'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt; &lt;span class="c1"&gt;//This decides the JWT TTL,  &lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;  

        &lt;span class="nv"&gt;$jws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$claims&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;userSigPrivKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;  
                &lt;span class="s1"&gt;'alg'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ALG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  
                &lt;span class="s1"&gt;'kid'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;userSigPrivKey&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'kid'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  
            &lt;span class="p"&gt;])&lt;/span&gt;  
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$jws&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
    &lt;span class="p"&gt;}&lt;/span&gt;  

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;createRT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  
    &lt;span class="p"&gt;{&lt;/span&gt;  
        &lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;bin2hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;  

        &lt;span class="nv"&gt;$rt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RefreshToken&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setExpires&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\DateTime&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;modify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'+30 days'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;//This decides the RT TTL &lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setIssued&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\DateTime&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;  

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$rt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
    &lt;span class="p"&gt;}&lt;/span&gt;  

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;generateJWT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;  
    &lt;span class="p"&gt;{&lt;/span&gt;  
        &lt;span class="nv"&gt;$jws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generateJWS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;serializeJWS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$jws&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
    &lt;span class="p"&gt;}&lt;/span&gt;  

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;verifyJWS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;JWS&lt;/span&gt; &lt;span class="nv"&gt;$jws&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;  
    &lt;span class="p"&gt;{&lt;/span&gt;  
        &lt;span class="nv"&gt;$valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;verifier&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;verifyWithKeySet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$jws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;userSigPubKeySet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$valid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
    &lt;span class="p"&gt;}&lt;/span&gt;  

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;JWSExpires&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;JWS&lt;/span&gt; &lt;span class="nv"&gt;$jws&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;\DateTime&lt;/span&gt;  
    &lt;span class="p"&gt;{&lt;/span&gt;  
        &lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$jws&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPayload&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;date_create_from_format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'U'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'exp'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;  

    &lt;span class="p"&gt;}&lt;/span&gt;  

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;serializeJWS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;JWS&lt;/span&gt; &lt;span class="nv"&gt;$jws&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;  
    &lt;span class="p"&gt;{&lt;/span&gt;  
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;serializer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$jws&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
    &lt;span class="p"&gt;}&lt;/span&gt;  

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;unserializeJWT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$JWT&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;JWS&lt;/span&gt;  
    &lt;span class="p"&gt;{&lt;/span&gt;  
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;serializer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;unserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$JWT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only things you might want to take a look at are the &lt;code&gt;createRT&lt;/code&gt; and &lt;code&gt;generateJWS&lt;/code&gt; functions these two might need adjusting depending on your situation. Primarily the &lt;code&gt;generateJWS&lt;/code&gt; function's data array. in this array you can store any information that you want to be signed. However this information can be decoded by anyone since its just base64. So only put things in there which are not super critical i.e. passwords or api keys.&lt;/p&gt;

&lt;p&gt;In this code the TTL of both the RT and JWT are defined, change them however you like, I personaly found JWT 10 min and RT 1 month to work well for my situation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Providing JWT's
&lt;/h2&gt;

&lt;p&gt;Now that we've got our &lt;code&gt;JWTService&lt;/code&gt; setup we need to create 3 endpoints&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;to share our public key set (JWKS)&lt;/li&gt;
&lt;li&gt;for the user to login and request an initial JWT and RT&lt;/li&gt;
&lt;li&gt;for exchanging an RT for a new JWT and RT&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is a simple controller that does these 3 things:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;  

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Controller&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Entity\RefreshToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Service\JWTService&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doctrine\ORM\EntityManagerInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Jose\Component\Core\JWKSet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Bundle\FrameworkBundle\Controller\AbstractController&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Routing\Attribute\Route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Security\Core\User\UserInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;JWTController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractController&lt;/span&gt;  
&lt;span class="p"&gt;{&lt;/span&gt;  
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;  
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;JWTService&lt;/span&gt;             &lt;span class="nv"&gt;$jwtService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;EntityManagerInterface&lt;/span&gt; &lt;span class="nv"&gt;$entityManager&lt;/span&gt;  
    &lt;span class="p"&gt;)&lt;/span&gt;  
    &lt;span class="p"&gt;{&lt;/span&gt;  
    &lt;span class="p"&gt;}&lt;/span&gt;  

    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/.well-known/jwks.json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'jwt_jwks'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;  
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;jwks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;JWKSet&lt;/span&gt; &lt;span class="nv"&gt;$userSigPubKeySet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  
    &lt;span class="p"&gt;{&lt;/span&gt;  
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userSigPubKeySet&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
    &lt;span class="p"&gt;}&lt;/span&gt;  

    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/jwt/request'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'jwt_request'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;  
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UserInterface&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  
    &lt;span class="p"&gt;{&lt;/span&gt;  
        &lt;span class="nv"&gt;$jws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;jwtService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generateJWS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
        &lt;span class="nv"&gt;$rt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;jwtService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createRT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  


        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;  
            &lt;span class="s1"&gt;'token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;jwtService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;serializeJWS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$jws&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  
            &lt;span class="s1"&gt;'token_expires'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;jwtService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;JWSExpires&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$jws&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'U'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  
            &lt;span class="s1"&gt;'refresh_token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$rt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getToken&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;  
            &lt;span class="s1"&gt;'refresh_expires'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$rt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getExpires&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'U'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  
        &lt;span class="p"&gt;]);&lt;/span&gt;  
    &lt;span class="p"&gt;}&lt;/span&gt;  

    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/jwt/refresh'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'jwt_refresh'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;])]&lt;/span&gt;  
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  
    &lt;span class="p"&gt;{&lt;/span&gt;  
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  
            &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getContent&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
            &lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'refresh_token'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;  
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Throwable&lt;/span&gt; &lt;span class="nv"&gt;$th&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Bad request'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
        &lt;span class="p"&gt;}&lt;/span&gt;  

        &lt;span class="nv"&gt;$oldRt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RefreshToken&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findOneBy&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;  

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$oldRt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'non-existent token'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
        &lt;span class="p"&gt;}&lt;/span&gt;  

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$oldRt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getExpires&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'now'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Token expired'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
        &lt;span class="p"&gt;}&lt;/span&gt;  

        &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$oldRt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$oldRt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  

        &lt;span class="nv"&gt;$jws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;jwtService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generateJWS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
        &lt;span class="nv"&gt;$newRt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;jwtService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createRT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$newRt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;  
            &lt;span class="s1"&gt;'token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;jwtService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;serializeJWS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$jws&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  
            &lt;span class="s1"&gt;'token_expires'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;jwtService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;JWSExpires&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$jws&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'U'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  
            &lt;span class="s1"&gt;'refresh_token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$newRt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getToken&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;  
            &lt;span class="s1"&gt;'refresh_expires'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$newRt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getExpires&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'U'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  
        &lt;span class="p"&gt;]);&lt;/span&gt;  
    &lt;span class="p"&gt;}&lt;/span&gt;  
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Again, you can and should change this controller in what ever way best suits your situation. &lt;/p&gt;

&lt;p&gt;Somethings to note, the &lt;code&gt;jwt_jwks&lt;/code&gt; is fully public. This is not a (big) security risk as all this allows is validation of the signature. however it is generally advisable to also limit access to this route if possible. &lt;/p&gt;

&lt;h2&gt;
  
  
  In closing
&lt;/h2&gt;

&lt;p&gt;Here are some things you should now also do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;rotate your keys&lt;/strong&gt;, I'd recommend setting up something that rotates the private key very day or so or on deployment, this is especially recommended if you keep the &lt;code&gt;jwt_jwks&lt;/code&gt; public.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleanup RT's&lt;/strong&gt; It is also recommended to set up a cron job that deletes any expired RT's since there is no reason to keep them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set up testing&lt;/strong&gt; setup proper testing to make sure your system is well protected.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have any questions or find problems in this guide I would love to help clarify / fix them &lt;a href="https://x.com/TomvdPeet" rel="noopener noreferrer"&gt;@TomvdPeet&lt;/a&gt;. But keep in mind that this guide is supposed to be followed loosely and modified to your needs.&lt;/p&gt;

&lt;p&gt;This article is 100% human written &lt;/p&gt;

&lt;p&gt;Lastly this guide is provided “as is” for informational purposes. Use at your own risk. No warranties. I accept no liability for any loss or damage arising from its use.&lt;/p&gt;

&lt;p&gt;Original article: &lt;a href="https://browsely.ai/blog/symfony-as-jwt-provider" rel="noopener noreferrer"&gt;https://browsely.ai/blog/symfony-as-jwt-provider&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>php</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
    <item>
      <title>Pivoting from Auth0 to PHP Symfony</title>
      <dc:creator>TomvdPeet</dc:creator>
      <pubDate>Wed, 20 Aug 2025 14:51:00 +0000</pubDate>
      <link>https://dev.to/tomvdpeet/pivoting-from-auth0-to-php-symfony-39h</link>
      <guid>https://dev.to/tomvdpeet/pivoting-from-auth0-to-php-symfony-39h</guid>
      <description>&lt;p&gt;These days when creating a system there are many different ways of setting up authentication. However the main options boil down to: a self made solution, a Aaas solution (authentication as a service) or a combination of the two, Aaas has become more a lot popular recently, with companies like OpenAI opting for this. And in the space of Aaas the most recognizable name is Auth0 (Partly i think because of OpenAI). &lt;/p&gt;

&lt;p&gt;The reason we needed authentication was to verify user in node.js. Node being our service for communicating between the user and the LLM. In this article we'll go over: why we went for Auth0, the problems we ran into, and why we pivoted to Symfony&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we chose for Auth0
&lt;/h2&gt;

&lt;p&gt;Starting out with Browsely we obviously wanted to get the product to the market as fast as possible, so we opted for what seemed like the easiest and quickest solution Aaas and specificly Auth0. Here is a list of perceived pros of Auth0: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt; Quick to implement, because we wouldn't have to build much&lt;/li&gt;
&lt;li&gt; Free to get started&lt;/li&gt;
&lt;li&gt; Social login off the bat &lt;/li&gt;
&lt;li&gt; No need to setup email and for: password reset and registration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These points seemed very appealing so we got started with implementing it and we very quickly started running into problems.&lt;/p&gt;

&lt;h3&gt;
  
  
  The problems with Auth0
&lt;/h3&gt;

&lt;p&gt;When implementing Auth0 the first problem we ran into is that its existing JS package is not really meant to be used for a web extension. From some things we came across online we found that supposedly in the past they did have support but it since has been removed. This meant we had to implement quite a lot of the authentication layer our self's. And because the JS package is meant to be used plug-and-play without much customization we had to back engineer it quite a bit to get it to work in a web extension. But eventually we did get it to work and did end up working as expected. &lt;/p&gt;

&lt;p&gt;Now the next problems arose when building the subscription layer, which we decided to do with  Stripe as payment provider. This created two issues 1. We now needed to store user data in two external systems, both of which needed to be kept up to date. 2. The user would now have to log in to our portal and in to the extension separately which we thaught would feel clunky. We did make a start on trying to solve the first problem but very quickly we felt the effort required to implement this neatly was to great at this stage in the project. And it was at this point we decided to pivot. &lt;/p&gt;

&lt;p&gt;Another problem not mentioned before is the pricing of Auth0 as we started using it we found the free packed permits quite a lot, and the payed plans seemingly less by comparison. Overall it was unclear when you'd have to pay and what you'd have to pay for. This problem was less pressing but one that could blow up at a later stage. &lt;/p&gt;

&lt;p&gt;A quick summary of the problems we ended up having with Auth0:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt; Messy to implement in to an extension&lt;/li&gt;
&lt;li&gt; Messy to set up with Stripe &lt;/li&gt;
&lt;li&gt; The user would have to login manually in two different places &lt;/li&gt;
&lt;li&gt; Vague pricing&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The pivot to Symfony
&lt;/h2&gt;

&lt;p&gt;Something i have to mention: our website was already built in Symfony. Now i hear you ask, why Symfony? That's simple, we know Symfony, so we can build fast and know what we can expect from it, now and moving forward. So logically we would migrate the whole auth system to it.&lt;/p&gt;

&lt;p&gt;One of the things we learned form our initial Auth0 implementation that we really liked was JWT. A protocol that would make centralized authentication much easier. So our setup would be: Symfony creates and signs a JWT and send it to the user, the user would send it with their request to node, node verifies based on the public key. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you want more technical information about how we setup Symfony as a JWT provider lookout for a future article where we go more in depth. Since we found there currently is not much information on how to do this with Symfony.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now, this setup is essentially the same way Auth0 works the only difference being that implementing this yourself gives a lot of liberties and options, allowing us to resolve most of the problems we had with Auth0. The implementation is much cleaner and allows for the user to simply log in to our website and they will automatically be signed in to the extension. We dont have to sync user data between both Auth0 and Stripe. And the pricing is very stable and predictable this way. The only downside thus far has been the lack of social login off the bat.&lt;/p&gt;

&lt;p&gt;We are now very happy about our decision to pivot and have learnt a lot from the whole experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;What have we learned. And what can you learn from this.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stick to what you know.&lt;/strong&gt; Building the auth system in Symfony ended up taking about as much time as setting up Auth0. Howerver the result is alot more robust and ready for future developments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't outsource to save time.&lt;/strong&gt; This is something we had already experienced in the past but at a small scale outsourcing usually costs about as much time if not more as doing it your self. There are exceptions like payment providers for example but generally, &lt;strong&gt;if you can do it yourself its usually faster&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control is important.&lt;/strong&gt; Keeping as much of your product in house as possible especially starting out. &lt;strong&gt;Its easier to outsource something at a later point than it is to take something back.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be ready to pivot&lt;/strong&gt; If you feel like a solution is causing more problems than its solving its most often time to look for a better solution.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Also i quickly want to mention that this article is not against Auth0 or Aaas in anyway, we think there are very valid use cases, especially for more traditionally structured  web-apps.&lt;/p&gt;

&lt;p&gt;Original article: &lt;a href="https://browsely.ai/blog/pivot-auth0-to-symfony" rel="noopener noreferrer"&gt;https://browsely.ai/blog/pivot-auth0-to-symfony&lt;/a&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
