Repository: shlinkio/shlink (language: php)

Issue:
Deprecate QR code generation
Some time ago I proposed some improvements on the QR code generation https://github.com/shlinkio/shlink/discussions/2234, but since then, I have came to the conclusion that Shlink should not be responsible of generating QR codes (see my comment in that same RFC https://github.com/shlinkio/shlink/discussions/2234#discussioncomment-12572580).

When it was first implemented, it made sense to have a convenient way to generate a QR code with an implicit content (the short URL) by simply appending `/qr-code` to any short URL.

However, as more config options have been added, this has become cumbersome, and even problematic. You don't want someone to generate a canonical URL from your own brand's domain, which resolves a QR code using your competition's brand colors, and distribute it around, for example.

This was also one of the reasons I was hesitant to allow QR codes to include logos, and ended up implementing a very limited version of that, which allows a pre-defined logo to be set, and only that one to be used.

So this has led me to think the QR code generation should be entirely handled by the client. I did a [very rough POC](https://github.com/shlinkio/shlink-web-component/pull/637) and this seems pretty easy to achieve. Additionally, more options can be added, like more flexible logo usage and support for other image formats, like webp.

The only limitation is that the image in the client can only be "downloaded", but you cannot get a canonical publicly-available URL. However, even this could be worked around by providing a way to generate base64-encoded data URIs.


Discussion / hints:
It appears that it is the way to go. 

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 818a25f1a..2807ddc01 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
 
+
+## [Unreleased]
+### Added
+* *Nothing*
+
+### Changed
+* *Nothing*
+
+### Deprecated
+* [#2408](https://github.com/shlinkio/shlink/issues/2408) Generating QR codes via `/{short-code}/qr-code` is now deprecated and will be removed in Shlink 5.0. Use the equivalent capability from web clients instead.
+
+### Removed
+* *Nothing*
+
+### Fixed
+* *Nothing*
+
+
 ## [4.4.6] - 2025-03-20
 ### Added
 * *Nothing*
diff --git a/composer.json b/composer.json
index 1123084d9..e35483946 100644
--- a/composer.json
+++ b/composer.json
@@ -43,7 +43,7 @@
         "pagerfanta/core": "^3.8",
         "ramsey/uuid": "^4.7",
         "shlinkio/doctrine-specification": "^2.2",
-        "shlinkio/shlink-common": "^7.0",
+        "shlinkio/shlink-common": "dev-main#e601317 as 7.1",
         "shlinkio/shlink-config": "^4.0",
         "shlinkio/shlink-event-dispatcher": "^4.2",
         "shlinkio/shlink-importer": "^5.6",
diff --git a/config/constants.php b/config/constants.php
index 09df0e606..f5dff4d54 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -13,13 +13,22 @@
 const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
 const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
 const LOOSE_URI_MATCHER = '/(.+)\:(.+)/i'; // Matches anything starting with a schema.
+const IP_ADDRESS_REQUEST_ATTRIBUTE = 'remote_address';
+const REDIRECT_URL_REQUEST_ATTRIBUTE = 'redirect_url';
+
+/** @deprecated */
 const DEFAULT_QR_CODE_SIZE = 300;
+/** @deprecated */
 const DEFAULT_QR_CODE_MARGIN = 0;
+/** @deprecated */
 const DEFAULT_QR_CODE_FORMAT = 'png';
+/** @deprecated */
 const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
+/** @deprecated */
 const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
+/** @deprecated */
 const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true;
+/** @deprecated */
 const DEFAULT_QR_CODE_COLOR = '#000000'; // Black
+/** @deprecated */
 const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White
-const IP_ADDRESS_REQUEST_ATTRIBUTE = 'remote_address';
-const REDIRECT_URL_REQUEST_ATTRIBUTE = 'redirect_url';
diff --git a/docs/swagger/paths/{shortCode}_qr-code.json b/docs/swagger/paths/{shortCode}_qr-code.json
index bb95f7ef1..dc0f2d810 100644
--- a/docs/swagger/paths/{shortCode}_qr-code.json
+++ b/docs/swagger/paths/{shortCode}_qr-code.json
@@ -1,11 +1,12 @@
 {
     "get": {
+        "deprecated": true,
         "operationId": "shortUrlQrCode",
         "tags": [
             "URL Shortener"
         ],
-        "summary": "Short URL QR code",
-        "description": "Generates a QR code image pointing to a short URL.<br />Since this is not an API endpoint but an image one, when an invalid value is provided for any of the query params, they will fall to their default values instead of throwing an error.",
+        "summary": "[Deprecated] Short URL QR code",
+        "description": "**[Deprecated]** Use an external mechanism to generate QR codes. Shlink dashboard and shlink-web-client provide their own.",
         "parameters": [
             {
                 "$ref": "../parameters/shortCode.json"
diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php
index 3b25b611b..9bdaf87e6 100644
--- a/module/Core/src/Action/Model/QrCodeParams.php
+++ b/module/Core/src/Action/Model/QrCodeParams.php
@@ -28,6 +28,7 @@
 use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR;
 use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR;
 
+/** @deprecated */
 final readonly class QrCodeParams
 {
     private const int MIN_SIZE = 50;
diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php
index 889d1d042..0ab126bcc 100644
--- a/module/Core/src/Action/QrCodeAction.php
+++ b/module/Core/src/Action/QrCodeAction.php
@@ -19,6 +19,7 @@
 use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
 use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
 
+/** @deprecated */
 readonly class QrCodeAction implements MiddlewareInterface
 {
     public function __construct(
diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php
index f8042febd..2a8359325 100644
--- a/module/Core/src/Config/EnvVars.php
+++ b/module/Core/src/Config/EnvVars.php
@@ -56,15 +56,6 @@ enum EnvVars: string
     case MATOMO_BASE_URL = 'MATOMO_BASE_URL';
     case MATOMO_SITE_ID = 'MATOMO_SITE_ID';
     case MATOMO_API_TOKEN = 'MATOMO_API_TOKEN';
-    case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE';
-    case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN';
-    case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT';
-    case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION';
-    case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE';
-    case QR_CODE_FOR_DISABLED_SHORT_URLS = 'QR_CODE_FOR_DISABLED_SHORT_URLS';
-    case DEFAULT_QR_CODE_COLOR = 'DEFAULT_QR_CODE_COLOR';
-    case DEFAULT_QR_CODE_BG_COLOR = 'DEFAULT_QR_CODE_BG_COLOR';
-    case DEFAULT_QR_CODE_LOGO_URL = 'DEFAULT_QR_CODE_LOGO_URL';
     case DEFAULT_INVALID_SHORT_URL_REDIRECT = 'DEFAULT_INVALID_SHORT_URL_REDIRECT';
     case DEFAULT_REGULAR_404_REDIRECT = 'DEFAULT_REGULAR_404_REDIRECT';
     case DEFAULT_BASE_URL_REDIRECT = 'DEFAULT_BASE_URL_REDIRECT';
@@ -95,6 +86,24 @@ enum EnvVars: string
     case SKIP_INITIAL_GEOLITE_DOWNLOAD = 'SKIP_INITIAL_GEOLITE_DOWNLOAD';
     /** @deprecated Use REDIRECT_EXTRA_PATH */
     case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH';
+    /** @deprecated */
+    case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE';
+    /** @deprecated */
+    case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN';
+    /** @deprecated */
+    case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT';
+    /** @deprecated */
+    case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION';
+    /** @deprecated */
+    case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE';
+    /** @deprecated */
+    case QR_CODE_FOR_DISABLED_SHORT_URLS = 'QR_CODE_FOR_DISABLED_SHORT_URLS';
+    /** @deprecated */
+    case DEFAULT_QR_CODE_COLOR = 'DEFAULT_QR_CODE_COLOR';
+    /** @deprecated */
+    case DEFAULT_QR_CODE_BG_COLOR = 'DEFAULT_QR_CODE_BG_COLOR';
+    /** @deprecated */
+    case DEFAULT_QR_CODE_LOGO_URL = 'DEFAULT_QR_CODE_LOGO_URL';
 
     public function loadFromEnv(): mixed
     {
diff --git a/module/Core/src/Config/Options/QrCodeOptions.php b/module/Core/src/Config/Options/QrCodeOptions.php
index ac864851c..e36a42853 100644
--- a/module/Core/src/Config/Options/QrCodeOptions.php
+++ b/module/Core/src/Config/Options/QrCodeOptions.php
@@ -15,6 +15,7 @@
 use const Shlinkio\Shlink\DEFAULT_QR_CODE_ROUND_BLOCK_SIZE;
 use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
 
+/** @deprecated */
 final readonly class QrCodeOptions
 {
     public function __construct(


Test patch:
diff --git a/module/Core/test-api/Action/QrCodeTest.php b/module/Core/test-api/Action/QrCodeTest.php
index 21fd5147f..c82851983 100644
--- a/module/Core/test-api/Action/QrCodeTest.php
+++ b/module/Core/test-api/Action/QrCodeTest.php
@@ -7,6 +7,7 @@
 use PHPUnit\Framework\Attributes\Test;
 use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
 
+/** @deprecated */
 class QrCodeTest extends ApiTestCase
 {
     #[Test]
diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php
index 4e9872775..5ea4cc476 100644
--- a/module/Core/test/Action/QrCodeActionTest.php
+++ b/module/Core/test/Action/QrCodeActionTest.php
@@ -9,7 +9,6 @@
 use Laminas\Diactoros\ServerRequestFactory;
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\Attributes\Test;
-use PHPUnit\Framework\Attributes\TestWith;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use Psr\Http\Message\ServerRequestInterface;
@@ -286,30 +285,6 @@ public function qrCodeIsResolvedBasedOnOptions(bool $enabledForDisabledShortUrls
         );
     }
 
-    #[Test]
-    #[TestWith([[], '4696E5'])] // The logo has Shlink's brand color
-    #[TestWith([['logo' => 'invalid'], '4696E5'])] // Invalid `logo` values are ignored. Default logo is still rendered
-    #[TestWith([['logo' => 'disable'], '000000'])] // No logo will be added if explicitly disabled
-    public function logoIsAddedToQrCodeIfOptionIsDefined(array $query, string $expectedColor): void
-    {
-        $logoUrl = 'https://avatars.githubusercontent.com/u/20341790?v=4'; // Shlink's logo
-        $code = 'abc123';
-        $req = ServerRequestFactory::fromGlobals()
-            ->withAttribute('shortCode', $code)
-            ->withQueryParams($query);
-
-        $this->urlResolver->method('resolveEnabledShortUrl')->willReturn(ShortUrl::withLongUrl('https://shlink.io'));
-        $handler = $this->createMock(RequestHandlerInterface::class);
-
-        $resp = $this->action(new QrCodeOptions(size: 250, logoUrl: $logoUrl))->process($req, $handler);
-        $image = imagecreatefromstring($resp->getBody()->__toString());
-        self::assertNotFalse($image);
-
-        // At around 100x100 px we can already find the logo, if present
-        $resultingColor = imagecolorat($image, 100, 100);
-        self::assertEquals(hexdec($expectedColor), $resultingColor);
-    }
-
     public static function provideEnabled(): iterable
     {
         yield 'always enabled' => [true];