Skip to content

Commit f97c67d

Browse files
committed
Merge branch 'master' into replace_rest_with_graphql_for_theme_level_support
2 parents 6ec7f20 + 112041f commit f97c67d

34 files changed

Lines changed: 828 additions & 74 deletions

.github/workflows/ci.yml

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,24 @@ jobs:
1010
max-parallel: 3
1111
matrix:
1212
php:
13-
- '8.1'
1413
- '8.2'
1514
- '8.3'
1615
- '8.4'
16+
- '8.5'
1717
analysis: [ false ]
1818
coverage: [ 'none' ]
1919
normalize: [ false ]
2020
validate: [ false ]
2121
laravel:
22-
- '10.48.29'
23-
- '11.45.1'
24-
- '12.19.3'
22+
- '12.0'
23+
- '13.0'
2524
exclude:
26-
- php: '8.1'
27-
laravel: '11.45.1'
28-
- php: '8.1'
29-
laravel: '12.19.3'
25+
# Laravel 13 supports PHP 8.3–8.5 only
26+
- php: '8.2'
27+
laravel: '13.0'
3028
include:
31-
- php: '8.1'
32-
laravel: '10.48.29'
29+
- php: '8.3'
30+
laravel: '13.0'
3331
analysis: true
3432
coverage: 'xdebug'
3533
normalize: true

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
composer.lock
55
.php-cs-fixer.cache
66
.phpunit.result.cache
7+
.phpunit.cache/
78

89
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
910
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
@@ -75,3 +76,4 @@ fabric.properties
7576
.idea/caches/build_file_checksums.ser
7677
/.idea/codeception.xml
7778
/.idea/phpspec.xml
79+

CONTRIBUTING.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ Thanks for wanting to be a part of this Laravel package! This is a simple and st
66

77
Well, you can:
88

9-
+ Tackle any [open issues](https://github.com/osiset/laravel-shopify/issues)
10-
+ Help review [pull requests](https://github.com/osiset/laravel-shopify/pulls)
11-
+ [Update documentation](https://github.com/osiset/laravel-shopify/wiki) in our wiki
9+
+ Tackle any [open issues](https://github.com/Kyon147/laravel-shopify/issues)
10+
+ Help review [pull requests](https://github.com/Kyon147/laravel-shopify/pulls)
11+
+ [Update documentation](https://github.com/Kyon147/laravel-shopify/wiki) in our wiki
1212
+ And more!
1313

1414
You don't have to be a superstar, or someone with experience, you can just dive in and help if you feel you can.
@@ -27,7 +27,7 @@ Its best to:
2727

2828
Its best to:
2929

30-
1. Ensure the bug was not already reported by [searching all issues](https://github.com/osiset/laravel-shopify/issues?q=).
30+
1. Ensure the bug was not already reported by [searching all issues](https://github.com/Kyon147/laravel-shopify/issues?q=).
3131
2. If you're unable to find an open issue addressing the problem, open a new one.
3232
* Be sure to include a title and clear description, as much relevant information as possible.
3333

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,20 @@ For more information, tutorials, etc., please view the project's [wiki](../../wi
4949

5050
For full resources on this package, see the [wiki](../..//wiki).
5151

52+
### Expiring offline access tokens
53+
54+
[Shopify requires expiring offline access tokens](https://shopify.dev/changelog/expiring-offline-access-tokens-required-for-public-apps-april-1-2026) for **new public apps** created on or after April 1, 2026. This package supports them when enabled:
55+
56+
1. Run package migrations so your shops table includes `shopify_offline_refresh_token`, `shopify_offline_access_token_expires_at`, and `shopify_offline_refresh_token_expires_at`.
57+
2. Set `SHOPIFY_EXPIRING_OFFLINE_TOKENS=true` in `.env` (see `expiring_offline_tokens` and `offline_access_token_refresh_skew_seconds` in `config/shopify-app.php`).
58+
3. Keep `APP_KEY` stable: refresh tokens are stored encrypted with Laravel’s encrypter.
59+
60+
Authorization code exchange, session-token exchange, and `refresh_token` grants are handled inside this package (`Osiset\ShopifyApp\Services\ApiHelper` and `OfflineAccessTokenRefresher`), not via `gnikyt/basic-shopify-api` updates. A valid access token is refreshed automatically before `apiHelper()` builds the API session when the offline token is expired or within the configured skew.
61+
62+
If your `User` model overrides `$casts`, merge `datetime` casts for the two `*_expires_at` columns (the `ShopModel` trait uses `mergeCasts` when `initializeShopModel` runs).
63+
64+
Longer term, consider replacing or forking `gnikyt/basic-shopify-api` for REST/Graph traffic if you need an actively maintained HTTP client; expiring offline OAuth is already decoupled from that dependency.
65+
5266
## Issue or request?
5367

5468
If you have found a bug or would like to request a feature for discussion, please use the `ISSUE_TEMPLATE` in this repo when creating your issue. Any issue submitted without this template will be closed.

composer.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,19 @@
3030
"source": "https://github.com/Kyon147/laravel-shopify"
3131
},
3232
"require": {
33-
"php": ">=8.1",
33+
"php": "^8.2",
3434
"ext-json": "*",
3535
"funeralzone/valueobjects": "^0.5",
36-
"gnikyt/basic-shopify-api": "^11.0",
36+
"gnikyt/basic-shopify-api": "^9.0 || ^10.0 || ^11.0",
3737
"jenssegers/agent": "^2.6",
38-
"laravel/framework": "^10.0 || ^11.0 || ^12.0"
38+
"laravel/framework": "^12.0 || ^13.0"
3939
},
4040
"require-dev": {
4141
"ergebnis/composer-normalize": "^2.8",
4242
"friendsofphp/php-cs-fixer": "^3.0",
4343
"laravel/legacy-factories": "^1.3.0",
4444
"mockery/mockery": "^1.0",
45-
"orchestra/testbench": "^8.0 || ^9.0",
45+
"orchestra/testbench": "^10.0 || ^11.0",
4646
"phpstan/phpstan": "^2.1",
4747
"phpunit/phpunit": "^8.0 || ^9.0 || ^10.0 || ^11.0"
4848
},

phpunit.xml.dist

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,44 @@
11
<?xml version="1.0" encoding="UTF-8"?>
2-
<phpunit
3-
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4-
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
5-
backupGlobals="false"
6-
colors="true"
7-
processIsolation="false"
8-
stopOnError="true"
9-
stopOnFailure="true"
10-
stopOnIncomplete="false"
11-
stopOnSkipped="false"
12-
cacheDirectory=".phpunit.cache"
13-
backupStaticProperties="false"
2+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
4+
backupGlobals="false"
5+
colors="true"
6+
processIsolation="false"
7+
stopOnError="true"
8+
stopOnFailure="true"
9+
stopOnIncomplete="false"
10+
stopOnSkipped="false"
11+
cacheDirectory=".phpunit.cache"
1412
>
15-
<testsuites>
16-
<testsuite name="Laravel Shopify Test Suite">
17-
<directory>tests</directory>
18-
</testsuite>
19-
</testsuites>
20-
<logging />
21-
<php>
22-
<env name="APP_KEY" value="AckfSECXIvnK5r28GVIWUAxmbBSjTsmF" />
23-
<env name="SHOPIFY_API_KEY" value="00000000000000000000000000000000" />
24-
<env name="SHOPIFY_API_SECRET" value="00000000000000000000000000000000" />
25-
</php>
2613
<source>
2714
<include>
2815
<directory suffix=".php">src/</directory>
2916
</include>
3017
<exclude>
3118
<directory>src/Contracts/</directory>
32-
<directory>src/Exceptions/</directory>
19+
<file>src/Exceptions/ApiException.php</file>
20+
<file>src/Exceptions/BaseException.php</file>
21+
<file>src/Exceptions/ChargeNotRecurringException.php</file>
22+
<file>src/Exceptions/ChargeNotRecurringOrOnetimeException.php</file>
23+
<file>src/Exceptions/HttpException.php</file>
24+
<file>src/Exceptions/InvalidShopDomainException.php</file>
25+
<file>src/Exceptions/MissingAuthUrlException.php</file>
26+
<file>src/Exceptions/MissingShopDomainException.php</file>
27+
<file>src/Exceptions/SignatureVerificationException.php</file>
3328
<directory>src/Objects/Enums/</directory>
3429
<directory>src/resources/</directory>
3530
<directory>src/Messaging/Events/</directory>
3631
<file>src/ShopifyAppProvider.php</file>
3732
</exclude>
3833
</source>
34+
<testsuites>
35+
<testsuite name="Laravel Shopify Test Suite">
36+
<directory>tests</directory>
37+
</testsuite>
38+
</testsuites>
39+
<php>
40+
<env name="APP_KEY" value="AckfSECXIvnK5r28GVIWUAxmbBSjTsmF"/>
41+
<env name="SHOPIFY_API_KEY" value="00000000000000000000000000000000"/>
42+
<env name="SHOPIFY_API_SECRET" value="00000000000000000000000000000000"/>
43+
</php>
3944
</phpunit>

src/Actions/InstallShop.php

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
namespace Osiset\ShopifyApp\Actions;
44

55
use Exception;
6+
use Illuminate\Support\Carbon;
67
use Osiset\ShopifyApp\Contracts\Commands\Shop as IShopCommand;
78
use Osiset\ShopifyApp\Contracts\Queries\Shop as IShopQuery;
9+
use Osiset\ShopifyApp\Contracts\ShopModel as IShopModel;
810
use Osiset\ShopifyApp\Objects\Enums\AuthMode;
911
use Osiset\ShopifyApp\Objects\Enums\ThemeSupportLevel as ThemeSupportLevelEnum;
1012
use Osiset\ShopifyApp\Objects\Values\AccessToken;
@@ -32,9 +34,9 @@ public function __invoke(ShopDomain $shopDomain, ?string $code = null, ?string $
3234
}
3335

3436
$apiHelper = $shop->apiHelper();
35-
$grantMode = $shop->hasOfflineAccess() ?
36-
AuthMode::fromNative(Util::getShopifyConfig('api_grant_mode', $shop)) :
37-
AuthMode::OFFLINE();
37+
$grantMode = $shop->hasOfflineAccess()
38+
? AuthMode::fromNative(Util::getShopifyConfig('api_grant_mode', $shop))
39+
: AuthMode::OFFLINE();
3840

3941
if (empty($code) && empty($idToken)) {
4042
return [
@@ -50,8 +52,10 @@ public function __invoke(ShopDomain $shopDomain, ?string $code = null, ?string $
5052
}
5153

5254
// Get the data and set the access token
53-
$data = $idToken !== null ? $apiHelper->performOfflineTokenExchange($idToken) : $apiHelper->getAccessData($code);
54-
$this->shopCommand->setAccessToken($shop->getId(), AccessToken::fromNative($data['access_token']));
55+
$data = $idToken !== null
56+
? $apiHelper->performOfflineTokenExchange($idToken)
57+
: $apiHelper->getAccessData($code, $grantMode);
58+
$this->persistShopifyOAuthTokens($shop, $data, $grantMode);
5559

5660
try {
5761
$themeSupportLevel = call_user_func($this->verifyThemeSupport, $shop->getId());
@@ -76,4 +80,36 @@ public function __invoke(ShopDomain $shopDomain, ?string $code = null, ?string $
7680
];
7781
}
7882
}
83+
84+
/**
85+
* Persist OAuth tokens and optional expiring-offline metadata.
86+
*
87+
* @param IShopModel $shop
88+
* @param mixed $data
89+
* @param AuthMode $grantMode
90+
*
91+
* @return void
92+
*/
93+
protected function persistShopifyOAuthTokens(IShopModel $shop, $data, AuthMode $grantMode): void
94+
{
95+
$expiringEnabled = Util::getShopifyConfig('expiring_offline_tokens', $shop);
96+
$isOfflineGrant = $grantMode->isSame(AuthMode::OFFLINE());
97+
98+
if ($expiringEnabled && $isOfflineGrant && isset($data['refresh_token'])) {
99+
$this->shopCommand->setAccessToken(
100+
$shop->getId(),
101+
AccessToken::fromNative($data['access_token']),
102+
$data['refresh_token'],
103+
Carbon::now()->addSeconds((int) $data['expires_in']),
104+
Carbon::now()->addSeconds((int) $data['refresh_token_expires_in'])
105+
);
106+
107+
return;
108+
}
109+
110+
$this->shopCommand->setAccessToken(
111+
$shop->getId(),
112+
AccessToken::fromNative($data['access_token'])
113+
);
114+
}
79115
}

src/Contracts/ApiHelper.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,23 @@ public function performOfflineTokenExchange(string $token): ResponseAccess;
7575
/**
7676
* Finish the process by getting the access details from the code.
7777
*
78-
* @param string $code The code from the request.
78+
* @param string $code The code from the request.
79+
* @param AuthMode|null $grantMode Offline vs per-user grant (defaults to offline).
7980
*
8081
* @return ResponseAccess
8182
*/
82-
public function getAccessData(string $code): ResponseAccess;
83+
public function getAccessData(string $code, ?AuthMode $grantMode = null): ResponseAccess;
84+
85+
/**
86+
* Refresh an expiring offline access token using a refresh token.
87+
*
88+
* @link https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens
89+
*
90+
* @param string $refreshToken The current offline refresh token.
91+
*
92+
* @return ResponseAccess
93+
*/
94+
public function refreshOfflineAccessToken(string $refreshToken): ResponseAccess;
8395

8496
/**
8597
* Get the script tags for the shop.

src/Contracts/Commands/Shop.php

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,24 @@ public function setToPlan(ShopIdValue $shopId, PlanIdValue $planId): bool;
3636
/**
3737
* Sets the access token (offline) from Shopify to the shop.
3838
*
39-
* @param ShopIdValue $shopId The shop's ID.
40-
* @param AccessTokenValue $token The token from Shopify Oauth.
39+
* When expiring offline tokens are used, pass the refresh token and expiry
40+
* timestamps; otherwise omit them to clear expiring-offline metadata.
41+
*
42+
* @param ShopIdValue $shopId The shop's ID.
43+
* @param AccessTokenValue $token The token from Shopify OAuth.
44+
* @param string|null $offlineRefreshTokenPlain Decrypted refresh token (stored encrypted).
45+
* @param \DateTimeInterface|null $offlineAccessTokenExpiresAt Access token expiry.
46+
* @param \DateTimeInterface|null $offlineRefreshTokenExpiresAt Refresh token expiry.
4147
*
4248
* @return bool
4349
*/
44-
public function setAccessToken(ShopIdValue $shopId, AccessTokenValue $token): bool;
50+
public function setAccessToken(
51+
ShopIdValue $shopId,
52+
AccessTokenValue $token,
53+
?string $offlineRefreshTokenPlain = null,
54+
$offlineAccessTokenExpiresAt = null,
55+
$offlineRefreshTokenExpiresAt = null
56+
): bool;
4557

4658
/**
4759
* Sets the Online Store 2.0 support level

src/Contracts/ShopModel.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ public function isFreemium(): bool;
7373
*/
7474
public function hasOfflineAccess(): bool;
7575

76+
/**
77+
* Whether the shop has expiring offline token metadata (encrypted refresh token stored).
78+
*
79+
* @return bool
80+
*/
81+
public function hasExpiringOfflineAccess(): bool;
82+
7683
/**
7784
* Get the API helper instance for a shop.
7885
* TODO: Find a better way than using resolve(). However, we can't inject in model constructors.

0 commit comments

Comments
 (0)