├── .editorconfig ├── .github └── workflows │ ├── coverage.yml │ ├── multi-tester.yml │ └── tests.yml ├── .multi-tester.yml ├── .styleci.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json └── src ├── DateTimeRange.php ├── Day.php ├── Exceptions ├── Exception.php ├── InvalidDate.php ├── InvalidDateRange.php ├── InvalidDateTimeClass.php ├── InvalidDayName.php ├── InvalidOpeningHoursSpecification.php ├── InvalidTimeRangeArray.php ├── InvalidTimeRangeList.php ├── InvalidTimeRangeString.php ├── InvalidTimeString.php ├── InvalidTimezone.php ├── MaximumLimitExceeded.php ├── NonMutableOffsets.php ├── OverlappingTimeRanges.php └── SearchLimitReached.php ├── Helpers ├── Arr.php ├── DataTrait.php ├── DateTimeCopier.php ├── DiffTrait.php └── RangeFinder.php ├── OpeningHours.php ├── OpeningHoursForDay.php ├── OpeningHoursSpecificationParser.php ├── PreciseTime.php ├── Time.php ├── TimeDataContainer.php └── TimeRange.php /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Coverage 2 | 3 | on: 4 | push: 5 | branches: [ '**' ] 6 | pull_request: 7 | branches: [ '**' ] 8 | 9 | jobs: 10 | coverage: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | php: ['8.3'] 16 | setup: ['stable'] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Setup PHP 22 | uses: shivammathur/setup-php@v2 23 | with: 24 | php-version: ${{ matrix.php }} 25 | tools: composer:v2 26 | coverage: true 27 | 28 | - name: Cache Composer packages 29 | id: composer-cache 30 | uses: actions/cache@v4 31 | with: 32 | path: vendor 33 | key: cov-${{ runner.os }}-php-${{ matrix.php }}-${{ matrix.setup }}-${{ hashFiles('**/composer.lock') }} 34 | restore-keys: cov-${{ runner.os }}-php-${{ matrix.php }}-${{ matrix.setup }}- 35 | 36 | - name: Install dependencies 37 | if: steps.composer-cache.outputs.cache-hit != 'true' 38 | run: | 39 | composer require --no-update scrutinizer/ocular; 40 | composer update --prefer-dist --no-progress --no-suggest --prefer-${{ matrix.setup || 'stable' }} ${{ matrix.php >= 8 && '--ignore-platform-req=php' || '' }}; 41 | 42 | - name: Run test suite 43 | run: vendor/bin/phpunit --coverage-text --coverage-clover=clover.xml 44 | 45 | - name: Coverage 46 | run: bash <(curl -s https://codecov.io/bash) 47 | -------------------------------------------------------------------------------- /.github/workflows/multi-tester.yml: -------------------------------------------------------------------------------- 1 | name: Multi-tester 2 | 3 | on: 4 | push: 5 | branches: [ '**' ] 6 | pull_request: 7 | branches: [ '**' ] 8 | 9 | jobs: 10 | multi-tester: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | php: ['8.3'] 16 | setup: ['stable'] 17 | 18 | name: PHP ${{ matrix.php }} - ${{ matrix.setup || 'stable' }} 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Setup PHP 24 | uses: shivammathur/setup-php@v2 25 | with: 26 | php-version: ${{ matrix.php }} 27 | tools: composer:v2 28 | coverage: none 29 | 30 | - name: Cache Composer packages 31 | id: composer-cache 32 | uses: actions/cache@v4 33 | with: 34 | path: vendor 35 | key: ${{ runner.os }}-php-${{ matrix.php }}-${{ matrix.setup }}-${{ hashFiles('**/composer.lock') }} 36 | restore-keys: ${{ runner.os }}-php-${{ matrix.php }}-${{ matrix.setup }}- 37 | 38 | - name: Install dependencies 39 | if: steps.composer-cache.outputs.cache-hit != 'true' 40 | run: composer update --prefer-dist --no-progress --no-suggest --prefer-${{ matrix.setup || 'stable' }} ${{ matrix.php >= 8 && '--ignore-platform-req=php' || '' }} 41 | 42 | - name: Run test suite 43 | run: vendor/bin/multi-tester 44 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ '**' ] 6 | pull_request: 7 | branches: [ '**' ] 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | php: ['8.2', '8.3', '8.4'] 17 | setup: ['lowest', 'stable'] 18 | 19 | name: PHP ${{ matrix.php }} - ${{ matrix.setup || 'stable' }} 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Setup PHP 25 | uses: shivammathur/setup-php@v2 26 | with: 27 | php-version: ${{ matrix.php }} 28 | tools: composer:v2 29 | coverage: none 30 | 31 | - name: Cache Composer packages 32 | id: composer-cache 33 | uses: actions/cache@v4 34 | with: 35 | path: vendor 36 | key: ${{ runner.os }}-php-${{ matrix.php }}-${{ matrix.setup }}-${{ hashFiles('**/composer.lock') }} 37 | restore-keys: ${{ runner.os }}-php-${{ matrix.php }}-${{ matrix.setup }}- 38 | 39 | - name: Install dependencies 40 | if: steps.composer-cache.outputs.cache-hit != 'true' 41 | run: composer update --prefer-dist --no-progress --no-suggest --prefer-${{ matrix.setup || 'stable' }} ${{ matrix.php >= 8 && '--ignore-platform-req=php' || '' }} 42 | 43 | - name: Run test suite 44 | run: vendor/bin/phpunit --no-coverage 45 | -------------------------------------------------------------------------------- /.multi-tester.yml: -------------------------------------------------------------------------------- 1 | cmixin/business-time: 2 | install: default 3 | script: default 4 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `opening-hours` will be documented in this file 4 | 5 | ## 4.0.0 - upcoming 6 | 7 | - Replace `getData()` with readonly property `->data` 8 | 9 | ## 3.0.0 - 2023-11-12 10 | 11 | - Add `Time::date()` method 12 | - Add `DateTimeRange` class 13 | - Add ranges support via `to` or `-` separator 14 | - Deprecate `fill()` and `setData()` 15 | - Remove `setFilters()` 16 | 17 | ## 2.41.0 - 2023-06-02 18 | 19 | - Cap holidays check to end date when calculating diff 20 | 21 | ## 2.13.0 - 2022-08-07 22 | 23 | - Make comparison microsecond-precise 24 | 25 | ## 2.12.0 - 2022-07-24 26 | 27 | - Apply timezone for all methods and both input/output 28 | 29 | ## 2.11.3 - 2022-07-23 30 | 31 | - Copy non immutable dates to apply timezone 32 | 33 | ## 2.11.2 - 2021-12-09 34 | 35 | - Add array-shape create() PHPDoc 36 | 37 | ## 2.11.1 - 2021-12-04 38 | 39 | - Fix compatibility with PHP 8.1 40 | 41 | ## 2.11.0 - 2021-10-16 42 | 43 | - Add dateTimeClass option to use other class for date objects 44 | 45 | ## 2.10.1 - 2020-12-10 46 | 47 | - Fix "hours" merge in mergeOverlappingRanges 48 | 49 | ## 2.10.0 - 2020-11-06 50 | 51 | - Add "hours" key support in mergeOverlappingRanges 52 | 53 | ## 2.9.1 - 2020-10-15 54 | 55 | - Use OpeningHours timezone for isOpenOn() 56 | 57 | ## 2.9.0 - 2020-09-03 58 | 59 | - Allow `isOpenOn()` to take date string as parameter 60 | 61 | ## 2.8.0 - 2020-06-19 62 | 63 | - Add `Time::diff()` methods 64 | 65 | ## 2.7.2 - 2020-06-19 66 | 67 | - Fix support of data/filters/overflow with 68 | `OpeningHours::createAndMergeOverlappingRanges()` and 69 | `OpeningHours::mergeOverlappingRanges()` 70 | 71 | ## 2.7.1 - 2020-05-30 72 | 73 | - Added `InvalidTimezone` exception 74 | 75 | ## 2.7.0 - 2019-08-27 76 | 77 | - Added `forWeekConsecutiveDays()` method 78 | 79 | ## 2.6.0 - 2019-07-18 80 | 81 | - Allowed to retrieve current and previous opening hours 82 | - Added `previousOpen()` 83 | - Added `previousClose()` 84 | - Added `currentOpenRange()` 85 | - Added `currentOpenRangeStart()` 86 | - Added `currentOpenRangeEnd()` 87 | 88 | ## 2.5.0 - 2019-06-19 89 | 90 | - Allowed [#128](https://github.com/spatie/opening-hours/issues/128) un-ordered ranges 91 | 92 | ## 2.4.1 - 2019-06-19 93 | 94 | - Added [#121](https://github.com/spatie/opening-hours/issues/121) timezone supporrt in `TimeRange::format()` 95 | 96 | ## 2.4.0 - 2019-06-19 97 | 98 | - Added [#121](https://github.com/spatie/opening-hours/issues/121) custom format and timezone support in `asStructuredData()` 99 | 100 | ## 2.3.3 - 2019-06-15 101 | 102 | - Fixed merge when last range of day ends with `24:00` 103 | 104 | ## 2.3.2 - 2019-06-10 105 | 106 | - Fixed [#115](https://github.com/spatie/opening-hours/issues/115) return `24:00` when `Time::fromString('24:00')` is casted to string 107 | 108 | ## 2.3.1 - 2019-06-07 109 | 110 | - Added a `MaximumLimitExceeded` exception to prevent infinite loop 111 | 112 | ## 2.3.0 - 2019-06-05 113 | 114 | ⚠ TimeRange no longer return true on containsTime for times overflowing next day. 115 | Overflow is now calculated at the day level (OpeningHoursForDay). 116 | 117 | - Added `OpeningHoursForDay::isOpenAtNight()` 118 | - Added `TimeRange::overflowsNextDay()` 119 | 120 | ## 2.2.1 - 2019-06-04 121 | 122 | - Fixed [#111](https://github.com/spatie/opening-hours/issues/111) overflow with simple ranges and add tests 123 | 124 | ## 2.2.0 - 2019-05-07 125 | 126 | - Allowed opening hours overflowing on the next day by passing `'overflow' => true` option in array definition 127 | 128 | ## 2.1.2 - 2019-03-14 129 | 130 | - Fixed [#98](https://github.com/spatie/opening-hours/issues/98) Set precise time bounds 131 | 132 | ## 2.1.1 - 2019-02-22 133 | 134 | - Fixed [#95](https://github.com/spatie/opening-hours/issues/95) Handle hours/data in any order 135 | 136 | ## 2.1.0 - 2019-02-18 137 | 138 | - Fixed [#88](https://github.com/spatie/opening-hours/issues/88) Opening hours across Midnight 139 | - Fixed [#89](https://github.com/spatie/opening-hours/issues/89) Data support for next open hours 140 | - Implemented [#93](https://github.com/spatie/opening-hours/issues/93) Enable PHP 8 141 | 142 | ## 2.0.0 - 2018-12-13 143 | 144 | - Added support for immutable dates 145 | - Allowed to add meta-data to global/exceptions config, days config, ranges settings via `setData()` and `getData()` 146 | - Allowed dynamic opening hours settings 147 | - Added `TimeRange::fromArray()` and `TimeRange::fromDefinition()` (to support array of hours+data or string[] or string) 148 | - Added `setFilters()` and `getFilters()` 149 | 150 | ⚠ Breaking changes: 151 | - `nextOpen()` and `nextClose()` return type changed for `DateTimeInterface` as it can now return `DateTimeImmutable` too 152 | - `toDateTime()` changed both input type and return type for `DateTimeInterface` as it can now take and return `DateTimeImmutable` too 153 | 154 | ## 1.9.0 - 2018-12-07 155 | 156 | - Allowed to merge overlapping hours [#43](https://github.com/spatie/opening-hours/issues/43) 157 | - Fixed `nextOpen()` and `nextClose()` consecutive calls [#73](https://github.com/spatie/opening-hours/issues/73) 158 | 159 | ## 1.8.1 - 2018-10-18 160 | 161 | - Added start time to overspilling timeranges 162 | 163 | ## 1.8.0 - 2018-09-17 164 | - Added `nextClose` 165 | 166 | ## 1.7.0 - 2018-08-02 167 | - Added additional helpers on `Time` 168 | 169 | ## 1.6.0 - 2018-03-26 170 | - Added the ability to pass a `DateTime` instance to mutate to `Time::toDateTime` 171 | 172 | ## 1.5.0 - 2018-02-26 173 | - Added `OpeningHours::forWeekCombined()` 174 | 175 | ## 1.4.0 - 2017-09-15 176 | - Added the ability to add recurring exceptions 177 | 178 | ## 1.3.1 - 2017-09-13 179 | - Fixed bug where checking on times starting at midnight would cause an infinite loop 180 | 181 | ## 1.3.0 - 2017-06-01 182 | - Added `regularClosingDays`, `regularClosingDaysISO` and `exceptionalClosingDates` methods 183 | 184 | ## 1.2.0 - 2017-01-03 185 | - Added `asStructuredData` to retrieve the opening hours as a Schema.org structured data array 186 | - Added `nextOpen` method to determine the next time the business will be open 187 | - Added utility methods: `OpeningHours::map`, `OpeningHours::flatMap`, `OpeningHours::mapExceptions`, `OpeningHours::flatMapExceptions`,`OpeningHoursForDay::map` and `OpeningHoursForDay::empty` 188 | 189 | ## 1.1.0 - 2016-11-09 190 | - Added timezone support 191 | 192 | ## 1.0.3 - 2016-10-18 193 | - `isClosedOn` fix 194 | 195 | ## 1.0.2 - 2016-10-13 196 | 197 | - Fixed missing import in `Time` class 198 | 199 | ## 1.0.1 - 2016-10-13 200 | 201 | - Replaced `DateTime` by `DateTimeInterface` 202 | 203 | ## 1.0.0 - 2016-10-07 204 | 205 | - First release 206 | 207 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) Spatie bvba 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | Logo for opening-hours 6 | 7 | 8 | 9 |

A helper to query and format a set of opening hours

10 | 11 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/spatie/opening-hours.svg?style=flat-square)](https://packagist.org/packages/spatie/opening-hours) 12 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 13 | [![Tests](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fspatie%2Fopening-hours%2Fbadge&style=flat-square&label=Build&logo=none)](https://actions-badge.atrox.dev/spatie/opening-hours/goto) 14 | [![Coverage](https://img.shields.io/codecov/c/github/spatie/opening-hours.svg?style=flat-square)](https://codecov.io/github/spatie/opening-hours?branch=master) 15 | [![Quality Score](https://img.shields.io/scrutinizer/g/spatie/opening-hours.svg?style=flat-square)](https://scrutinizer-ci.com/g/spatie/opening-hours) 16 | [![StyleCI](https://styleci.io/repos/69368104/shield?branch=master)](https://styleci.io/repos/69368104) 17 | [![Total Downloads](https://img.shields.io/packagist/dt/spatie/opening-hours.svg?style=flat-square)](https://packagist.org/packages/spatie/opening-hours) 18 | 19 |
20 | 21 | With `spatie/opening-hours` you create an object that describes a business' opening hours, which you can query for `open` or `closed` on days or specific dates, or use to present the times per day. 22 | 23 | `spatie/opening-hours` can be used directly on [Carbon](https://carbon.nesbot.com/) thanks 24 | to [cmixin/business-time](https://github.com/kylekatarnls/business-time) so you can benefit 25 | opening hours features directly on your enhanced date objects. 26 | 27 | A set of opening hours is created by passing in a regular schedule, and a list of exceptions. 28 | 29 | ```php 30 | // Add the use at the top of each file where you want to use the OpeningHours class: 31 | use Spatie\OpeningHours\OpeningHours; 32 | 33 | $openingHours = OpeningHours::create([ 34 | 'monday' => ['09:00-12:00', '13:00-18:00'], 35 | 'tuesday' => ['09:00-12:00', '13:00-18:00'], 36 | 'wednesday' => ['09:00-12:00'], 37 | 'thursday' => ['09:00-12:00', '13:00-18:00'], 38 | 'friday' => ['09:00-12:00', '13:00-20:00'], 39 | 'saturday' => ['09:00-12:00', '13:00-16:00'], 40 | 'sunday' => [], 41 | 'exceptions' => [ 42 | '2016-11-11' => ['09:00-12:00'], 43 | '2016-12-25' => [], 44 | '01-01' => [], // Recurring on each 1st of January 45 | '12-25' => ['09:00-12:00'], // Recurring on each 25th of December 46 | ], 47 | ]); 48 | 49 | // This will allow you to display things like: 50 | 51 | $now = new DateTime('now'); 52 | $range = $openingHours->currentOpenRange($now); 53 | 54 | if ($range) { 55 | echo "It's open since ".$range->start()."\n"; 56 | echo "It will close at ".$range->end()."\n"; 57 | } else { 58 | echo "It's closed since ".$openingHours->previousClose($now)->format('l H:i')."\n"; 59 | echo "It will re-open at ".$openingHours->nextOpen($now)->format('l H:i')."\n"; 60 | } 61 | ``` 62 | 63 | The object can be queried for a day in the week, which will return a result based on the regular schedule: 64 | 65 | ```php 66 | // Open on Mondays: 67 | $openingHours->isOpenOn('monday'); // true 68 | 69 | // Closed on Sundays: 70 | $openingHours->isOpenOn('sunday'); // false 71 | ``` 72 | 73 | It can also be queried for a specific date and time: 74 | 75 | ```php 76 | // Closed because it's after hours: 77 | $openingHours->isOpenAt(new DateTime('2016-09-26 19:00:00')); // false 78 | 79 | // Closed because Christmas was set as an exception 80 | $openingHours->isOpenOn('2016-12-25'); // false 81 | ``` 82 | 83 | It can also return arrays of opening hours for a week or a day: 84 | 85 | ```php 86 | // OpeningHoursForDay object for the regular schedule 87 | $openingHours->forDay('monday'); 88 | 89 | // OpeningHoursForDay[] for the regular schedule, keyed by day name 90 | $openingHours->forWeek(); 91 | 92 | // Array of day with same schedule for the regular schedule, keyed by day name, days combined by working hours 93 | $openingHours->forWeekCombined(); 94 | 95 | // OpeningHoursForDay object for a specific day 96 | $openingHours->forDate(new DateTime('2016-12-25')); 97 | 98 | // OpeningHoursForDay[] of all exceptions, keyed by date 99 | $openingHours->exceptions(); 100 | ``` 101 | 102 | On construction, you can set a flag for overflowing times across days. For example, for a nightclub opens until 3am on Friday and Saturday: 103 | 104 | ```php 105 | $openingHours = \Spatie\OpeningHours\OpeningHours::create([ 106 | 'overflow' => true, 107 | 'friday' => ['20:00-03:00'], 108 | 'saturday' => ['20:00-03:00'], 109 | ], null); 110 | ``` 111 | 112 | This allows the API to further at previous day's data to check if the opening hours are open from its time range. 113 | 114 | You can add data in definitions then retrieve them: 115 | 116 | ```php 117 | $openingHours = OpeningHours::create([ 118 | 'monday' => [ 119 | 'data' => 'Typical Monday', 120 | '09:00-12:00', 121 | '13:00-18:00', 122 | ], 123 | 'tuesday' => [ 124 | '09:00-12:00', 125 | '13:00-18:00', 126 | [ 127 | '19:00-21:00', 128 | 'data' => 'Extra on Tuesday evening', 129 | ], 130 | ], 131 | 'exceptions' => [ 132 | '2016-12-25' => [ 133 | 'data' => 'Closed for Christmas', 134 | ], 135 | ], 136 | ]); 137 | 138 | echo $openingHours->forDay('monday')->data; // Typical Monday 139 | echo $openingHours->forDate(new DateTime('2016-12-25'))->data; // Closed for Christmas 140 | echo $openingHours->forDay('tuesday')[2]->data; // Extra on Tuesday evening 141 | ``` 142 | 143 | In the example above, data are strings but it can be any kind of value. So you can embed multiple properties in an array. 144 | 145 | For structure convenience, the data-hours couple can be a fully-associative array, so the example above is strictly equivalent to the following: 146 | 147 | ```php 148 | $openingHours = OpeningHours::create([ 149 | 'monday' => [ 150 | 'hours' => [ 151 | '09:00-12:00', 152 | '13:00-18:00', 153 | ], 154 | 'data' => 'Typical Monday', 155 | ], 156 | 'tuesday' => [ 157 | ['hours' => '09:00-12:00'], 158 | ['hours' => '13:00-18:00'], 159 | ['hours' => '19:00-21:00', 'data' => 'Extra on Tuesday evening'], 160 | ], 161 | // Open by night from Wednesday 22h to Thursday 7h: 162 | 'wednesday' => ['22:00-24:00'], // use the special "24:00" to reach midnight included 163 | 'thursday' => ['00:00-07:00'], 164 | 'exceptions' => [ 165 | '2016-12-25' => [ 166 | 'hours' => [], 167 | 'data' => 'Closed for Christmas', 168 | ], 169 | ], 170 | ]); 171 | ``` 172 | 173 | You can use the separator `to` to specify multiple days at once, for the week or for exceptions: 174 | 175 | ```php 176 | $openingHours = OpeningHours::create([ 177 | 'monday to friday' => ['09:00-19:00'], 178 | 'saturday to sunday' => [], 179 | 'exceptions' => [ 180 | // Every year 181 | '12-24 to 12-26' => [ 182 | 'hours' => [], 183 | 'data' => 'Holidays', 184 | ], 185 | // Only happening in 2024 186 | '2024-06-25 to 2024-07-01' => [ 187 | 'hours' => [], 188 | 'data' => 'Closed for works', 189 | ], 190 | ], 191 | ]); 192 | ``` 193 | 194 | The last structure tool is the filter, it allows you to pass closures (or callable function/method reference) that take a date as a parameter and returns the settings for the given date. 195 | 196 | ```php 197 | $openingHours = OpeningHours::create([ 198 | 'monday' => [ 199 | '09:00-12:00', 200 | ], 201 | 'filters' => [ 202 | function ($date) { 203 | $year = intval($date->format('Y')); 204 | $easterMonday = new DateTimeImmutable('2018-03-21 +'.(easter_days($year) + 1).'days'); 205 | if ($date->format('m-d') === $easterMonday->format('m-d')) { 206 | return []; // Closed on Easter Monday 207 | // Any valid exception-array can be returned here (range of hours, with or without data) 208 | } 209 | // Else the filter does not apply to the given date 210 | }, 211 | ], 212 | ]); 213 | ``` 214 | 215 | If a callable is found in the `"exceptions"` property, it will be added automatically to filters so you can mix filters and exceptions both in the **exceptions** array. The first filter that returns a non-null value will have precedence over the next filters and the **filters** array has precedence over the filters inside the **exceptions** array. 216 | 217 | Warning: We will loop on all filters for each date from which we need to retrieve opening hours and can neither predicate nor cache the result (can be a random function) so you must be careful with filters, too many filters or long process inside filters can have a significant impact on the performance. 218 | 219 | It can also return the next open or close `DateTime` from a given `DateTime`. 220 | 221 | ```php 222 | // The next open datetime is tomorrow morning, because we’re closed on 25th of December. 223 | $nextOpen = $openingHours->nextOpen(new DateTime('2016-12-25 10:00:00')); // 2016-12-26 09:00:00 224 | 225 | // The next open datetime is this afternoon, after the lunch break. 226 | $nextOpen = $openingHours->nextOpen(new DateTime('2016-12-24 11:00:00')); // 2016-12-24 13:00:00 227 | 228 | 229 | // The next close datetime is at noon. 230 | $nextClose = $openingHours->nextClose(new DateTime('2016-12-24 10:00:00')); // 2016-12-24 12:00:00 231 | 232 | // The next close datetime is tomorrow at noon, because we’re closed on 25th of December. 233 | $nextClose = $openingHours->nextClose(new DateTime('2016-12-25 15:00:00')); // 2016-12-26 12:00:00 234 | ``` 235 | 236 | Read the usage section for the full api. 237 | 238 | Spatie is a webdesign agency based in Antwerp, Belgium. You'll find an overview of all our open source projects [on our website](https://spatie.be/opensource). 239 | 240 | ## Support us 241 | 242 | [](https://spatie.be/github-ad-click/opening-hours) 243 | 244 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 245 | 246 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). 247 | 248 | ## Installation 249 | 250 | You can install the package via composer: 251 | 252 | ``` bash 253 | composer require spatie/opening-hours 254 | ``` 255 | 256 | ## Usage 257 | 258 | The package should only be used through the `OpeningHours` class. There are also three value object classes used throughout, `Time`, which represents a single time, `TimeRange`, which represents a period with a start and an end, and `openingHoursForDay`, which represents a set of `TimeRange`s which can't overlap. 259 | 260 | ### `Spatie\OpeningHours\OpeningHours` 261 | 262 | #### `OpeningHours::create(array $data, $timezone = null, $toutputTimezone = null): Spatie\OpeningHours\OpeningHours` 263 | 264 | Static factory method to fill the set of opening hours. 265 | 266 | ```php 267 | $openingHours = OpeningHours::create([ 268 | 'monday' => ['09:00-12:00', '13:00-18:00'], 269 | // ... 270 | ]); 271 | ``` 272 | 273 | If no timezone is specified, `OpeningHours` will just assume you always 274 | pass `DateTime` objects that have already the timezone matching your schedule. 275 | 276 | If you pass a `$timezone` as a second argument or via the array-key `'timezone'` 277 | (it can be either a `DateTimeZone` object or a `string`), then passed dates will 278 | be converted to this timezone at the beginning of each method, then if the method 279 | return a date object (such as `nextOpen`, `nextClose`, `previousOpen`, 280 | `previousClose`, `currentOpenRangeStart` or `currentOpenRangeEnd`), then it's 281 | converted back to original timezone before output so the object can reflect 282 | a moment in user local time while `OpeningHours` can stick in its own business 283 | timezone. 284 | 285 | Alternatively you can also specify both input and output timezone (using second 286 | and third argument) or using an array: 287 | ```php 288 | $openingHours = OpeningHours::create([ 289 | 'monday' => ['09:00-12:00', '13:00-18:00'], 290 | 'timezone' => [ 291 | 'input' => 'America/New_York', 292 | 'output' => 'Europe/Oslo', 293 | ], 294 | ]); 295 | ``` 296 | 297 | #### `OpeningHours::mergeOverlappingRanges(array $schedule) : array` 298 | 299 | For safety sake, creating `OpeningHours` object with overlapping ranges will throw an exception unless you pass explicitly `'overflow' => true,` in the opening hours array definition. You can also explicitly merge them. 300 | 301 | ```php 302 | $ranges = [ 303 | 'monday' => ['08:00-11:00', '10:00-12:00'], 304 | ]; 305 | $mergedRanges = OpeningHours::mergeOverlappingRanges($ranges); // Monday becomes ['08:00-12:00'] 306 | 307 | OpeningHours::create($mergedRanges); 308 | // Or use the following shortcut to create from ranges that possibly overlap: 309 | OpeningHours::createAndMergeOverlappingRanges($ranges); 310 | ``` 311 | 312 | Not all days are mandatory, if a day is missing, it will be set as closed. 313 | 314 | #### `OpeningHours::fill(array $data): Spatie\OpeningHours\OpeningHours` 315 | 316 | The same as `create`, but non-static. 317 | 318 | ```php 319 | $openingHours = (new OpeningHours)->fill([ 320 | 'monday' => ['09:00-12:00', '13:00-18:00'], 321 | // ... 322 | ]); 323 | ``` 324 | 325 | #### `OpeningHours::forWeek(): Spatie\OpeningHours\OpeningHoursForDay[]` 326 | 327 | Returns an array of `OpeningHoursForDay` objects for a regular week. 328 | 329 | ```php 330 | $openingHours->forWeek(); 331 | ``` 332 | 333 | #### `OpeningHours::forWeekCombined(): array` 334 | 335 | Returns an array of days. Array key is first day with same hours, array values are days that have the same working hours and `OpeningHoursForDay` object. 336 | 337 | ```php 338 | $openingHours->forWeekCombined(); 339 | ``` 340 | 341 | #### `OpeningHours::forWeekConsecutiveDays(): array` 342 | 343 | Returns an array of concatenated days, adjacent days with the same hours. Array key is first day with same hours, array values are days that have the same working hours and `OpeningHoursForDay` object. 344 | 345 | *Warning*: consecutive days are considered from Monday to Sunday without looping (Monday is not consecutive to Sunday) no matter the days order in initial data. 346 | 347 | ```php 348 | $openingHours->forWeekConsecutiveDays(); 349 | ``` 350 | 351 | #### `OpeningHours::forDay(string $day): Spatie\OpeningHours\OpeningHoursForDay` 352 | 353 | Returns an `OpeningHoursForDay` object for a regular day. A day is lowercase string of the english day name. 354 | 355 | ```php 356 | $openingHours->forDay('monday'); 357 | ``` 358 | 359 | #### `OpeningHours::forDate(DateTimeInterface $dateTime): Spatie\OpeningHours\OpeningHoursForDay` 360 | 361 | Returns an `OpeningHoursForDay` object for a specific date. It looks for an exception on that day, and otherwise it returns the opening hours based on the regular schedule. 362 | 363 | ```php 364 | $openingHours->forDate(new DateTime('2016-12-25')); 365 | ``` 366 | 367 | #### `OpeningHours::exceptions(): Spatie\OpeningHours\OpeningHoursForDay[]` 368 | 369 | Returns an array of all `OpeningHoursForDay` objects for exceptions, keyed by a `Y-m-d` date string. 370 | 371 | ```php 372 | $openingHours->exceptions(); 373 | ``` 374 | 375 | #### `OpeningHours::isOpenOn(string $day): bool` 376 | 377 | Checks if the business is open (contains at least 1 range of open hours) on a day in the regular schedule. 378 | 379 | ```php 380 | $openingHours->isOpenOn('saturday'); 381 | ``` 382 | 383 | If the given string is a date, it will check if it's open (contains at least 1 range of open hours) considering 384 | both regular day schedule and possible exceptions. 385 | 386 | ```php 387 | $openingHours->isOpenOn('2020-09-03'); 388 | $openingHours->isOpenOn('09-03'); // If year is omitted, current year is used instead 389 | ``` 390 | 391 | #### `OpeningHours::isClosedOn(string $day): bool` 392 | 393 | Checks if the business is closed on a day in the regular schedule. 394 | 395 | ```php 396 | $openingHours->isClosedOn('sunday'); 397 | ``` 398 | 399 | #### `OpeningHours::isOpenAt(DateTimeInterface $dateTime): bool` 400 | 401 | Checks if the business is open on a specific day, at a specific time. 402 | 403 | ```php 404 | $openingHours->isOpenAt(new DateTime('2016-26-09 20:00')); 405 | ``` 406 | 407 | #### `OpeningHours::isClosedAt(DateTimeInterface $dateTime): bool` 408 | 409 | Checks if the business is closed on a specific day, at a specific time. 410 | 411 | ```php 412 | $openingHours->isClosedAt(new DateTime('2016-26-09 20:00')); 413 | ``` 414 | 415 | #### `OpeningHours::isOpen(): bool` 416 | 417 | Checks if the business is open right now. 418 | 419 | ```php 420 | $openingHours->isOpen(); 421 | ``` 422 | 423 | #### `OpeningHours::isClosed(): bool` 424 | 425 | Checks if the business is closed right now. 426 | 427 | ```php 428 | $openingHours->isClosed(); 429 | ``` 430 | 431 | #### `OpeningHours::isAlwaysOpen(): bool` 432 | 433 | Checks if the business is open 24/7, has no exceptions and no filters. 434 | 435 | ```php 436 | if ($openingHours->isAlwaysOpen()) { 437 | echo 'This business is open all day long every day.'; 438 | } 439 | ``` 440 | 441 | #### `OpeningHours::isAlwaysClosed(): bool` 442 | 443 | Checks if the business is never open, has no exceptions and no filters. 444 | 445 | `OpeningHours` accept empty array or list with every week day empty with no prejudices. 446 | 447 | If it's not a valid state in your domain, you should use this method to throw an exception 448 | or show an error. 449 | 450 | ```php 451 | if ($openingHours->isAlwaysClosed()) { 452 | throw new RuntimeException('Opening hours missing'); 453 | } 454 | ``` 455 | 456 | #### `OpeningHours::nextOpen` 457 | 458 | ```php 459 | OpeningHours::nextOpen( 460 | ?DateTimeInterface $dateTime = null, 461 | ?DateTimeInterface $searchUntil = null, 462 | ?DateTimeInterface $cap = null, 463 | ) : DateTimeInterface` 464 | ``` 465 | 466 | Returns next open `DateTime` from the given `DateTime` (`$dateTime` or from now if this parameter is null or omitted). 467 | 468 | If a `DateTimeImmutable` object is passed, a `DateTimeImmutable` object is returned. 469 | 470 | Set `$searchUntil` to a date to throw an exception if no open time can be found before this moment. 471 | 472 | Set `$cap` to a date so if no open time can be found before this moment, `$cap` is returned. 473 | 474 | ```php 475 | $openingHours->nextOpen(new DateTime('2016-12-24 11:00:00')); 476 | ``` 477 | 478 | #### `OpeningHours::nextClose` 479 | 480 | ```php 481 | OpeningHours::nextClose( 482 | ?DateTimeInterface $dateTime = null, 483 | ?DateTimeInterface $searchUntil = null, 484 | ?DateTimeInterface $cap = null, 485 | ) : DateTimeInterface` 486 | ``` 487 | 488 | Returns next close `DateTime` from the given `DateTime` (`$dateTime` or from now if this parameter is null or omitted). 489 | 490 | If a `DateTimeImmutable` object is passed, a `DateTimeImmutable` object is returned. 491 | 492 | Set `$searchUntil` to a date to throw an exception if no closed time can be found before this moment. 493 | 494 | Set `$cap` to a date so if no closed time can be found before this moment, `$cap` is returned. 495 | 496 | If the schedule is always open or always closed, there is no state change to found and therefore 497 | `nextOpen` (but also `previousOpen`, `nextClose` and `previousClose`) will throw a `MaximumLimitExceeded` 498 | You can catch it and react accordingly or you can use `isAlwaysOpen` / `isAlwaysClosed` methods 499 | to anticipate such case. 500 | 501 | ```php 502 | $openingHours->nextClose(new DateTime('2016-12-24 11:00:00')); 503 | ``` 504 | 505 | #### `OpeningHours::previousOpen` 506 | 507 | ```php 508 | OpeningHours::previousOpen( 509 | ?DateTimeInterface $dateTime = null, 510 | ?DateTimeInterface $searchUntil = null, 511 | ?DateTimeInterface $cap = null, 512 | ) : DateTimeInterface` 513 | ``` 514 | 515 | Returns previous open `DateTime` from the given `DateTime` (`$dateTime` or from now if this parameter is null or omitted). 516 | 517 | If a `DateTimeImmutable` object is passed, a `DateTimeImmutable` object is returned. 518 | 519 | Set `$searchUntil` to a date to throw an exception if no open time can be found after this moment. 520 | 521 | Set `$cap` to a date so if no open time can be found after this moment, `$cap` is returned. 522 | 523 | ```php 524 | $openingHours->previousOpen(new DateTime('2016-12-24 11:00:00')); 525 | ``` 526 | 527 | #### `OpeningHours::previousClose` 528 | 529 | ```php 530 | OpeningHours::previousClose( 531 | ?DateTimeInterface $dateTime = null, 532 | ?DateTimeInterface $searchUntil = null, 533 | ?DateTimeInterface $cap = null, 534 | ) : DateTimeInterface` 535 | ``` 536 | 537 | Returns previous close `DateTime` from the given `DateTime` (`$dateTime` or from now if this parameter is null or omitted). 538 | 539 | If a `DateTimeImmutable` object is passed, a `DateTimeImmutable` object is returned. 540 | 541 | Set `$searchUntil` to a date to throw an exception if no closed time can be found after this moment. 542 | 543 | Set `$cap` to a date so if no closed time can be found after this moment, `$cap` is returned. 544 | 545 | ```php 546 | $openingHours->nextClose(new DateTime('2016-12-24 11:00:00')); 547 | ``` 548 | 549 | #### `OpeningHours::diffInOpenHours(DateTimeInterface $startDate, DateTimeInterface $endDate) : float` 550 | 551 | Return the amount of open time (number of hours as a floating number) between 2 dates/times. 552 | 553 | ```php 554 | $openingHours->diffInOpenHours(new DateTime('2016-12-24 11:00:00'), new DateTime('2016-12-24 16:34:25')); 555 | ``` 556 | 557 | #### `OpeningHours::diffInOpenMinutes(DateTimeInterface $startDate, DateTimeInterface $endDate) : float` 558 | 559 | Return the amount of open time (number of minutes as a floating number) between 2 dates/times. 560 | 561 | #### `OpeningHours::diffInOpenSeconds(DateTimeInterface $startDate, DateTimeInterface $endDate) : float` 562 | 563 | Return the amount of open time (number of seconds as a floating number) between 2 dates/times. 564 | 565 | #### `OpeningHours::diffInClosedHours(DateTimeInterface $startDate, DateTimeInterface $endDate) : float` 566 | 567 | Return the amount of closed time (number of hours as a floating number) between 2 dates/times. 568 | 569 | ```php 570 | $openingHours->diffInClosedHours(new DateTime('2016-12-24 11:00:00'), new DateTime('2016-12-24 16:34:25')); 571 | ``` 572 | 573 | #### `OpeningHours::diffInClosedMinutes(DateTimeInterface $startDate, DateTimeInterface $endDate) : float` 574 | 575 | Return the amount of closed time (number of minutes as a floating number) between 2 dates/times. 576 | 577 | #### `OpeningHours::diffInClosedSeconds(DateTimeInterface $startDate, DateTimeInterface $endDate) : float` 578 | 579 | Return the amount of closed time (number of seconds as a floating number) between 2 dates/times. 580 | 581 | #### `OpeningHours::currentOpenRange(DateTimeInterface $dateTime) : false | TimeRange` 582 | 583 | Returns a `Spatie\OpeningHours\TimeRange` instance of the current open range if the 584 | business is open, false if the business is closed. 585 | 586 | ```php 587 | $range = $openingHours->currentOpenRange(new DateTime('2016-12-24 11:00:00')); 588 | 589 | if ($range) { 590 | echo "It's open since ".$range->start()."\n"; 591 | echo "It will close at ".$range->end()."\n"; 592 | } else { 593 | echo "It's closed"; 594 | } 595 | ``` 596 | 597 | `start()` and `end()` methods return `Spatie\OpeningHours\Time` instances. `Time` 598 | instances created from a date can be formatted with date information. This is useful 599 | for ranges overflowing midnight: 600 | 601 | ```php 602 | $period = $openingHours->currentOpenRange(new DateTime('2016-12-24 11:00:00')); 603 | 604 | if ($period) { 605 | echo "It's open since ".$period->start()->format('D G\h')."\n"; 606 | echo "It will close at ".$period->end()->format('D G\h')."\n"; 607 | } else { 608 | echo "It's closed"; 609 | } 610 | ``` 611 | 612 | #### `OpeningHours::currentOpenRangeStart(DateTimeInterface $dateTime) : false | DateTime` 613 | 614 | Returns a `DateTime` instance of the date and time since when the business is open if 615 | the business is open, false if the business is closed. 616 | 617 | Note: date can be the previous day if you use night ranges. 618 | 619 | ```php 620 | $date = $openingHours->currentOpenRangeStart(new DateTime('2016-12-24 11:00:00')); 621 | 622 | if ($date) { 623 | echo "It's open since ".$date->format('H:i'); 624 | } else { 625 | echo "It's closed"; 626 | } 627 | ``` 628 | 629 | #### `OpeningHours::currentOpenRangeEnd(DateTimeInterface $dateTime) : false | DateTime` 630 | 631 | Returns a `DateTime` instance of the date and time until when the business will be open 632 | if the business is open, false if the business is closed. 633 | 634 | Note: date can be the next day if you use night ranges. 635 | 636 | ```php 637 | $date = $openingHours->currentOpenRangeEnd(new DateTime('2016-12-24 11:00:00')); 638 | 639 | if ($date) { 640 | echo "It will close at ".$date->format('H:i'); 641 | } else { 642 | echo "It's closed"; 643 | } 644 | ``` 645 | 646 | #### `OpeningHours::createFromStructuredData(array|string $data, $timezone = null, $outputTimezone = null): Spatie\OpeningHours\OpeningHours` 647 | 648 | Static factory method to fill the set with a https://schema.org/OpeningHoursSpecification array or JSON string. 649 | 650 | `dayOfWeek` supports array of day names (Google-flavored) or array of day URLs (official schema.org specification). 651 | 652 | ```php 653 | $openingHours = OpeningHours::createFromStructuredData('[ 654 | { 655 | "@type": "OpeningHoursSpecification", 656 | "opens": "08:00", 657 | "closes": "12:00", 658 | "dayOfWeek": [ 659 | "https://schema.org/Monday", 660 | "https://schema.org/Tuesday", 661 | "https://schema.org/Wednesday", 662 | "https://schema.org/Thursday", 663 | "https://schema.org/Friday" 664 | ] 665 | }, 666 | { 667 | "@type": "OpeningHoursSpecification", 668 | "opens": "14:00", 669 | "closes": "18:00", 670 | "dayOfWeek": [ 671 | "Monday", 672 | "Tuesday", 673 | "Wednesday", 674 | "Thursday", 675 | "Friday" 676 | ] 677 | }, 678 | { 679 | "@type": "OpeningHoursSpecification", 680 | "opens": "00:00", 681 | "closes": "00:00", 682 | "validFrom": "2023-12-25", 683 | "validThrough": "2023-12-25" 684 | } 685 | ]'); 686 | ``` 687 | 688 | #### `OpeningHours::asStructuredData(strinf $format = 'H:i', string|DateTimeZone $timezone) : array` 689 | 690 | Returns a [OpeningHoursSpecification](https://schema.org/openingHoursSpecification) as an array. 691 | 692 | ```php 693 | $openingHours->asStructuredData(); 694 | $openingHours->asStructuredData('H:i:s'); // Customize time format, could be 'h:i a', 'G:i', etc. 695 | $openingHours->asStructuredData('H:iP', '-05:00'); // Add a timezone 696 | // Timezone can be numeric or string like "America/Toronto" or a DateTimeZone instance 697 | // But be careful, the time is arbitrary applied on 1970-01-01, so it does not handle daylight 698 | // saving time, meaning Europe/Paris is always +01:00 even in summer time. 699 | ``` 700 | 701 | ### `Spatie\OpeningHours\OpeningHoursForDay` 702 | 703 | This class is meant as read-only. It implements `ArrayAccess`, `Countable` and `IteratorAggregate` so you can process the list of `TimeRange`s in an array-like way. 704 | 705 | ### `Spatie\OpeningHours\TimeRange` 706 | 707 | Value object describing a period with a start and an end time. Can be cast to a string in a `H:i-H:i` format. 708 | 709 | ### `Spatie\OpeningHours\Time` 710 | 711 | Value object describing a single time. Can be cast to a string in a `H:i` format. 712 | 713 | ## Adapters 714 | 715 | ### OpenStreetMap 716 | 717 | You can convert OpenStreetMap format to `OpeningHours` object using [osm-opening-hours](https://github.com/ujamii/osm-opening-hours) (thanks to [mgrundkoetter](https://github.com/mgrundkoetter)) 718 | 719 | ## Changelog 720 | 721 | Please see [CHANGELOG](CHANGELOG.md) for more information about what has changed recently. 722 | 723 | ## Testing 724 | 725 | ``` bash 726 | composer test 727 | ``` 728 | 729 | ## Contributing 730 | 731 | Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. 732 | 733 | ## Security 734 | 735 | If you've found a bug regarding security please mail [security@spatie.be](mailto:security@spatie.be) instead of using the issue tracker. 736 | 737 | ## Postcardware 738 | 739 | You're free to use this package, but if it makes it to your production environment we highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. 740 | 741 | Our address is: Spatie, Kruikstraat 22, 2018 Antwerp, Belgium. 742 | 743 | We publish all received postcards [on our company website](https://spatie.be/en/opensource/postcards). 744 | 745 | ## Credits 746 | 747 | - [Sebastian De Deyne](https://github.com/sebastiandedeyne) 748 | - [All Contributors](../../contributors) 749 | 750 | ## License 751 | 752 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 753 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spatie/opening-hours", 3 | "description": "A helper to query and format a set of opening hours", 4 | "keywords": [ 5 | "spatie", 6 | "opening-hours", 7 | "schedule", 8 | "opening", 9 | "hours" 10 | ], 11 | "homepage": "https://github.com/spatie/opening-hours", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Sebastian De Deyne", 16 | "email": "sebastian@spatie.be", 17 | "homepage": "https://spatie.be", 18 | "role": "Developer" 19 | }, 20 | { 21 | "name": "kylekatarnls", 22 | "homepage": "https://github.com/kylekatarnls", 23 | "role": "Developer" 24 | } 25 | ], 26 | "require": { 27 | "php": "^8.2" 28 | }, 29 | "require-dev": { 30 | "kylekatarnls/multi-tester": "^2.5", 31 | "phpunit/phpunit": "^11.2" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "Spatie\\OpeningHours\\": "src" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "Spatie\\OpeningHours\\Test\\": "tests" 41 | } 42 | }, 43 | "scripts": { 44 | "test": "vendor/bin/phpunit" 45 | }, 46 | "config": { 47 | "sort-packages": true 48 | }, 49 | "extra": { 50 | "branch-alias": { 51 | "dev-master": "4.x-dev", 52 | "dev-3.x": "3.x-dev", 53 | "dev-2.x": "2.x-dev" 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/DateTimeRange.php: -------------------------------------------------------------------------------- 1 | copyAndModify($date, $start.( 16 | $start > $date->format(self::TIME_FORMAT) 17 | ? ' - 1 day' 18 | : '' 19 | )); 20 | $endDate = $this->copyAndModify($date, $end.( 21 | $end < $date->format(self::TIME_FORMAT) 22 | ? ' + 1 day' 23 | : '' 24 | )); 25 | parent::__construct( 26 | Time::fromString($start, $start->data, $startDate), 27 | Time::fromString($end, $start->data, $endDate), 28 | $data, 29 | ); 30 | } 31 | 32 | public static function fromTimeRange(DateTimeInterface $date, TimeRange $timeRange, mixed $data = null) 33 | { 34 | return new self($date, $timeRange->start, $timeRange->end, $data); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Day.php: -------------------------------------------------------------------------------- 1 | format('l')); 22 | } 23 | 24 | public static function fromName(string $day): self 25 | { 26 | try { 27 | return self::from(strtolower($day)); 28 | } catch (ValueError $exception) { 29 | throw InvalidDayName::invalidDayName($day, $exception); 30 | } 31 | } 32 | 33 | public function toISO(): int 34 | { 35 | return array_search($this, self::cases()) + 1; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Exceptions/Exception.php: -------------------------------------------------------------------------------- 1 | format('Y-m-d H:i:s.u e')); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Helpers/Arr.php: -------------------------------------------------------------------------------- 1 | data readonly property instead 9 | */ 10 | public function getData(): mixed 11 | { 12 | return $this->data; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Helpers/DateTimeCopier.php: -------------------------------------------------------------------------------- 1 | copyDateTime($date)->modify($modifier); 18 | } 19 | 20 | protected function yesterday(DateTimeInterface $date): DateTimeInterface 21 | { 22 | return $this->copyAndModify($date, '-1 day'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Helpers/DiffTrait.php: -------------------------------------------------------------------------------- 1 | diffInSeconds($stateCheckMethod, $nextDateMethod, $skipDateMethod, $endDate, $startDate); 20 | } 21 | 22 | $date = $startDate; 23 | 24 | while ($date < $endDate) { 25 | if ($this->$stateCheckMethod($date)) { 26 | $date = $this->$skipDateMethod($date, null, $endDate); 27 | 28 | continue; 29 | } 30 | 31 | $nextDate = min($endDate, $this->$nextDateMethod($date, null, $endDate)); 32 | $time += ((float) $nextDate->format('U.u')) - ((float) $date->format('U.u')); 33 | $date = $nextDate; 34 | } 35 | 36 | return $time; 37 | } 38 | 39 | /** 40 | * Return the amount of open time (number of seconds as a floating number) between 2 dates/times. 41 | * 42 | * @param DateTimeInterface $startDate 43 | * @param DateTimeInterface $endDate 44 | * @return float 45 | */ 46 | public function diffInOpenSeconds(DateTimeInterface $startDate, DateTimeInterface $endDate): float 47 | { 48 | return $this->diffInSeconds('isClosedAt', 'nextClose', 'nextOpen', $startDate, $endDate); 49 | } 50 | 51 | /** 52 | * Return the amount of open time (number of minutes as a floating number) between 2 dates/times. 53 | * 54 | * @param DateTimeInterface $startDate 55 | * @param DateTimeInterface $endDate 56 | * @return float 57 | */ 58 | public function diffInOpenMinutes(DateTimeInterface $startDate, DateTimeInterface $endDate): float 59 | { 60 | return $this->diffInOpenSeconds($startDate, $endDate) / 60; 61 | } 62 | 63 | /** 64 | * Return the amount of open time (number of hours as a floating number) between 2 dates/times. 65 | * 66 | * @param DateTimeInterface $startDate 67 | * @param DateTimeInterface $endDate 68 | * @return float 69 | */ 70 | public function diffInOpenHours(DateTimeInterface $startDate, DateTimeInterface $endDate): float 71 | { 72 | return $this->diffInOpenMinutes($startDate, $endDate) / 60; 73 | } 74 | 75 | /** 76 | * Return the amount of closed time (number of seconds as a floating number) between 2 dates/times. 77 | * 78 | * @param DateTimeInterface $startDate 79 | * @param DateTimeInterface $endDate 80 | * @return float 81 | */ 82 | public function diffInClosedSeconds(DateTimeInterface $startDate, DateTimeInterface $endDate): float 83 | { 84 | return $this->diffInSeconds('isOpenAt', 'nextOpen', 'nextClose', $startDate, $endDate); 85 | } 86 | 87 | /** 88 | * Return the amount of closed time (number of minutes as a floating number) between 2 dates/times. 89 | * 90 | * @param DateTimeInterface $startDate 91 | * @param DateTimeInterface $endDate 92 | * @return float 93 | */ 94 | public function diffInClosedMinutes(DateTimeInterface $startDate, DateTimeInterface $endDate): float 95 | { 96 | return $this->diffInClosedSeconds($startDate, $endDate) / 60; 97 | } 98 | 99 | /** 100 | * Return the amount of closed time (number of hours as a floating number) between 2 dates/times. 101 | * 102 | * @param DateTimeInterface $startDate 103 | * @param DateTimeInterface $endDate 104 | * @return float 105 | */ 106 | public function diffInClosedHours(DateTimeInterface $startDate, DateTimeInterface $endDate): float 107 | { 108 | return $this->diffInClosedMinutes($startDate, $endDate) / 60; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Helpers/RangeFinder.php: -------------------------------------------------------------------------------- 1 | isBefore($timeRange->start()) ? $timeRange : null; 13 | } 14 | 15 | protected function findOpenInFreeTime(Time $time, TimeRange $timeRange): ?Time 16 | { 17 | return $this->findRangeInFreeTime($time, $timeRange)?->start(); 18 | } 19 | 20 | protected function findOpenRangeInWorkingHours(Time $time, TimeRange $timeRange): ?TimeRange 21 | { 22 | return $time->isAfter($timeRange->start()) ? $timeRange : null; 23 | } 24 | 25 | protected function findOpenInWorkingHours(Time $time, TimeRange $timeRange): ?Time 26 | { 27 | return $this->findOpenRangeInWorkingHours($time, $timeRange)?->start(); 28 | } 29 | 30 | protected function findCloseInWorkingHours(Time $time, TimeRange $timeRange): ?Time 31 | { 32 | return $timeRange->containsTime($time) ? $timeRange->end() : null; 33 | } 34 | 35 | protected function findCloseRangeInWorkingHours(Time $time, TimeRange $timeRange): ?TimeRange 36 | { 37 | return $timeRange->containsTime($time) ? $timeRange : null; 38 | } 39 | 40 | protected function findCloseInFreeTime(Time $time, TimeRange $timeRange): ?Time 41 | { 42 | return $this->findRangeInFreeTime($time, $timeRange)?->end(); 43 | } 44 | 45 | protected function findPreviousRangeInFreeTime(Time $time, TimeRange $timeRange): ?TimeRange 46 | { 47 | return $time->isAfter($timeRange->end()) && $time->isAfter($timeRange->start()) ? $timeRange : null; 48 | } 49 | 50 | protected function findPreviousOpenInFreeTime(Time $time, TimeRange $timeRange): ?Time 51 | { 52 | return $this->findPreviousRangeInFreeTime($time, $timeRange)?->start(); 53 | } 54 | 55 | protected function findPreviousCloseInWorkingHours(Time $time, TimeRange $timeRange): ?Time 56 | { 57 | $end = $timeRange->end(); 58 | 59 | return $time->isAfter($end) ? $end : null; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/OpeningHours.php: -------------------------------------------------------------------------------- 1 | setTimezone($timezone); 62 | $this->setOutputTimezone($outputTimezone); 63 | 64 | $days = Day::cases(); 65 | 66 | $timezones = array_key_exists('timezone', $data) ? $data['timezone'] : []; 67 | unset($data['timezone']); 68 | 69 | if (! is_array($timezones)) { 70 | $timezones = ['input' => $timezones]; 71 | } 72 | 73 | if (array_key_exists('input', $timezones)) { 74 | $this->timezone = $this->parseTimezone($timezones['input']); 75 | } 76 | 77 | if (array_key_exists('output', $timezones)) { 78 | $this->outputTimezone = $this->parseTimezone($timezones['output']); 79 | } 80 | 81 | [$openingHours, $exceptions, $metaData, $filters, $overflow, $dateTimeClass] = $this 82 | ->parseOpeningHoursAndExceptions($data); 83 | 84 | $this->overflow = $overflow; 85 | 86 | $this->openingHours = array_combine( 87 | array_map(static fn (Day $day) => $day->value, $days), 88 | array_map(fn (Day $day) => $this->getOpeningHoursFromStrings($openingHours[$day->value] ?? []), $days), 89 | ); 90 | 91 | $this->setExceptionsFromStrings($exceptions); 92 | $this->data = $metaData; 93 | $this->filters = $filters; 94 | 95 | if ($dateTimeClass !== null && ! is_a($dateTimeClass, DateTimeInterface::class, true)) { 96 | throw InvalidDateTimeClass::forString($dateTimeClass); 97 | } 98 | 99 | $this->dateTimeClass = $dateTimeClass ?? DateTime::class; 100 | } 101 | 102 | /** 103 | * @param array{ 104 | * monday?: array, 105 | * tuesday?: array, 106 | * wednesday?: array, 107 | * thursday?: array, 108 | * friday?: array, 109 | * saturday?: array, 110 | * sunday?: array, 111 | * exceptions?: array>, 112 | * filters?: callable[], 113 | * overflow?: bool, 114 | * data?: mixed, 115 | * dateTimeClass?: class-string, 116 | * } $data 117 | * @param string|DateTimeZone|null $timezone 118 | * @param string|DateTimeZone|null $outputTimezone 119 | * @return static 120 | */ 121 | public static function create( 122 | array $data, 123 | string|DateTimeZone|null $timezone = null, 124 | string|DateTimeZone|null $outputTimezone = null, 125 | ): self { 126 | return new static($data, $timezone, $outputTimezone); 127 | } 128 | 129 | public static function createFromStructuredData( 130 | array|string $structuredData, 131 | string|DateTimeZone|null $timezone = null, 132 | string|DateTimeZone|null $outputTimezone = null, 133 | ): self { 134 | return new static( 135 | array_merge( 136 | // https://schema.org/OpeningHoursSpecification allows overflow by default 137 | ['overflow' => true], 138 | OpeningHoursSpecificationParser::create($structuredData)->getOpeningHours(), 139 | ), 140 | $timezone, 141 | $outputTimezone, 142 | ); 143 | } 144 | 145 | /** 146 | * @param array $data hours definition array or sub-array 147 | * @param bool $ignoreData should ignore data 148 | * @param array $excludedKeys keys to ignore from parsing 149 | * @return array 150 | */ 151 | public static function mergeOverlappingRanges(array $data, bool $ignoreData = true, array $excludedKeys = ['data', 'dateTimeClass', 'filters', 'overflow']): array 152 | { 153 | $result = []; 154 | $ranges = []; 155 | 156 | foreach (static::filterHours($data, $excludedKeys) as $key => [$value, $data]) { 157 | $dataString = $ignoreData ? '' : json_encode($data); 158 | 159 | $value = is_array($value) 160 | ? static::mergeOverlappingRanges($value, $ignoreData) 161 | : (is_string($value) ? TimeRange::fromString($value, $data) : $value); 162 | 163 | if ($value instanceof TimeRange) { 164 | $newRanges = []; 165 | 166 | foreach (($ranges[$dataString] ?? []) as $range) { 167 | if ($value->format() === $range->format()) { 168 | continue 2; 169 | } 170 | 171 | if ($value->overlaps($range) || $range->overlaps($value)) { 172 | $value = TimeRange::fromList([$value, $range], $data); 173 | 174 | continue; 175 | } 176 | 177 | $newRanges[] = $range; 178 | } 179 | 180 | $newRanges[] = $value; 181 | $ranges[$dataString] = $newRanges; 182 | 183 | continue; 184 | } 185 | 186 | $result[$key] = $value; 187 | } 188 | 189 | foreach ($ranges as $range) { 190 | foreach ((array) $range as $rangeItem) { 191 | $result[] = $rangeItem; 192 | } 193 | } 194 | 195 | return $result; 196 | } 197 | 198 | /** 199 | * @param array{ 200 | * monday?: array, 201 | * tuesday?: array, 202 | * wednesday?: array, 203 | * thursday?: array, 204 | * friday?: array, 205 | * saturday?: array, 206 | * sunday?: array, 207 | * exceptions?: array>, 208 | * filters?: callable[], 209 | * overflow?: bool, 210 | * } $data 211 | * @param string|DateTimeZone|null $timezone 212 | * @param string|DateTimeZone|null $outputTimezone 213 | * @param bool $ignoreData 214 | * @return static 215 | */ 216 | public static function createAndMergeOverlappingRanges(array $data, $timezone = null, $outputTimezone = null, bool $ignoreData = true): self 217 | { 218 | return static::create(static::mergeOverlappingRanges($data, $ignoreData), $timezone, $outputTimezone); 219 | } 220 | 221 | /** 222 | * @param array $data 223 | * @return bool 224 | */ 225 | public static function isValid(array $data): bool 226 | { 227 | try { 228 | static::create($data); 229 | 230 | return true; 231 | } catch (Exception) { 232 | return false; 233 | } 234 | } 235 | 236 | /** 237 | * Set the number of days to try before abandoning the search of the next close/open time. 238 | * 239 | * @param int $dayLimit number of days 240 | * @return $this 241 | */ 242 | public function setDayLimit(int $dayLimit): self 243 | { 244 | $this->dayLimit = $dayLimit; 245 | 246 | return $this; 247 | } 248 | 249 | /** 250 | * Get the number of days to try before abandoning the search of the next close/open time. 251 | * 252 | * @return int 253 | */ 254 | public function getDayLimit(): int 255 | { 256 | return $this->dayLimit ?: static::DEFAULT_DAY_LIMIT; 257 | } 258 | 259 | public function getFilters(): array 260 | { 261 | return $this->filters; 262 | } 263 | 264 | public function forWeek(): array 265 | { 266 | return $this->openingHours; 267 | } 268 | 269 | public function forWeekCombined(): array 270 | { 271 | $equalDays = []; 272 | $allOpeningHours = $this->openingHours; 273 | $uniqueOpeningHours = array_unique($allOpeningHours); 274 | $nonUniqueOpeningHours = $allOpeningHours; 275 | 276 | foreach ($uniqueOpeningHours as $day => $value) { 277 | $equalDays[$day] = ['days' => [$day], 'opening_hours' => $value]; 278 | unset($nonUniqueOpeningHours[$day]); 279 | } 280 | 281 | foreach ($uniqueOpeningHours as $uniqueDay => $uniqueValue) { 282 | foreach ($nonUniqueOpeningHours as $nonUniqueDay => $nonUniqueValue) { 283 | if ((string) $uniqueValue === (string) $nonUniqueValue) { 284 | $equalDays[$uniqueDay]['days'][] = $nonUniqueDay; 285 | } 286 | } 287 | } 288 | 289 | return $equalDays; 290 | } 291 | 292 | public function forWeekConsecutiveDays(): array 293 | { 294 | $concatenatedDays = []; 295 | $allOpeningHours = $this->openingHours; 296 | 297 | foreach ($allOpeningHours as $day => $value) { 298 | $previousDay = end($concatenatedDays); 299 | 300 | if ($previousDay && (string) $previousDay['opening_hours'] === (string) $value) { 301 | $key = key($concatenatedDays); 302 | $concatenatedDays[$key]['days'][] = $day; 303 | 304 | continue; 305 | } 306 | 307 | $concatenatedDays[$day] = [ 308 | 'opening_hours' => $value, 309 | 'days' => [$day], 310 | ]; 311 | } 312 | 313 | return $concatenatedDays; 314 | } 315 | 316 | public function forDay(Day|string $day): OpeningHoursForDay 317 | { 318 | return $this->openingHours[$this->normalizeDayName($day)]; 319 | } 320 | 321 | public function forDate(DateTimeInterface $date): OpeningHoursForDay 322 | { 323 | $date = $this->applyTimezone($date); 324 | 325 | foreach ($this->filters as $filter) { 326 | $result = $filter($date); 327 | 328 | if (is_array($result)) { 329 | return OpeningHoursForDay::fromStrings($result); 330 | } 331 | } 332 | 333 | return $this->exceptions[$date->format('Y-m-d')] 334 | ?? $this->exceptions[$date->format('m-d')] 335 | ?? $this->forDay(Day::onDateTime($date)); 336 | } 337 | 338 | /** 339 | * @param DateTimeInterface $date 340 | * @return TimeRange[] 341 | */ 342 | public function forDateTime(DateTimeInterface $date): array 343 | { 344 | $date = $this->applyTimezone($date); 345 | 346 | return array_merge( 347 | iterator_to_array($this->forDate( 348 | $this->yesterday($date), 349 | )->forNightTime(Time::fromDateTime($date))), 350 | iterator_to_array($this->forDate($date)->forTime(Time::fromDateTime($date))), 351 | ); 352 | } 353 | 354 | public function exceptions(): array 355 | { 356 | return $this->exceptions; 357 | } 358 | 359 | public function isOpenOn(string $day): bool 360 | { 361 | if (preg_match('/^(?:(\d+)-)?(\d{1,2})-(\d{1,2})$/', $day, $match)) { 362 | [, $year, $month, $day] = $match; 363 | $year = $year ?: date('Y'); 364 | 365 | return count($this->forDate(new DateTimeImmutable("$year-$month-$day", $this->timezone))) > 0; 366 | } 367 | 368 | return count($this->forDay($day)) > 0; 369 | } 370 | 371 | public function isClosedOn(string $day): bool 372 | { 373 | return ! $this->isOpenOn($day); 374 | } 375 | 376 | public function isOpenAt(DateTimeInterface $dateTime): bool 377 | { 378 | $dateTime = $this->applyTimezone($dateTime); 379 | 380 | if ($this->overflow) { 381 | $dateTimeMinus1Day = $this->yesterday($dateTime); 382 | $openingHoursForDayBefore = $this->forDate($dateTimeMinus1Day); 383 | 384 | if ($openingHoursForDayBefore->isOpenAtNight(PreciseTime::fromDateTime($dateTimeMinus1Day))) { 385 | return true; 386 | } 387 | } 388 | 389 | $openingHoursForDay = $this->forDate($dateTime); 390 | 391 | return $openingHoursForDay->isOpenAt(PreciseTime::fromDateTime($dateTime)); 392 | } 393 | 394 | public function isClosedAt(DateTimeInterface $dateTime): bool 395 | { 396 | return ! $this->isOpenAt($dateTime); 397 | } 398 | 399 | public function isOpen(): bool 400 | { 401 | return $this->isOpenAt(new $this->dateTimeClass()); 402 | } 403 | 404 | public function isClosed(): bool 405 | { 406 | return $this->isClosedAt(new $this->dateTimeClass()); 407 | } 408 | 409 | public function currentOpenRange(DateTimeInterface $dateTime): ?DateTimeRange 410 | { 411 | $dateTime = $this->applyTimezone($dateTime); 412 | $list = $this->forDateTime($dateTime); 413 | $range = end($list); 414 | 415 | return $range ? DateTimeRange::fromTimeRange($dateTime, $range) : null; 416 | } 417 | 418 | public function currentOpenRangeStart(DateTimeInterface $dateTime): ?DateTimeInterface 419 | { 420 | $outputTimezone = $this->getOutputTimezone($dateTime); 421 | $dateTime = $this->applyTimezone($dateTime); 422 | /** @var TimeRange $range */ 423 | $range = $this->currentOpenRange($dateTime); 424 | 425 | if (! $range) { 426 | return null; 427 | } 428 | 429 | $dateTime = $this->copyDateTime($dateTime); 430 | 431 | $nextDateTime = $range->start()->toDateTime(); 432 | 433 | if ($range->overflowsNextDay() && $nextDateTime->format('Hi') > $dateTime->format('Hi')) { 434 | $dateTime = $dateTime->modify('-1 day'); 435 | } 436 | 437 | return $this->getDateWithTimezone( 438 | $dateTime->setTime($nextDateTime->format('G'), $nextDateTime->format('i'), 0), 439 | $outputTimezone 440 | ); 441 | } 442 | 443 | public function currentOpenRangeEnd(DateTimeInterface $dateTime): ?DateTimeInterface 444 | { 445 | $outputTimezone = $this->getOutputTimezone($dateTime); 446 | $dateTime = $this->applyTimezone($dateTime); 447 | /** @var TimeRange $range */ 448 | $range = $this->currentOpenRange($dateTime); 449 | 450 | if (! $range) { 451 | return null; 452 | } 453 | 454 | $dateTime = $this->copyDateTime($dateTime); 455 | 456 | $nextDateTime = $range->end()->toDateTime(); 457 | 458 | if ($range->overflowsNextDay() && $nextDateTime->format('Hi') < $dateTime->format('Hi')) { 459 | $dateTime = $dateTime->modify('+1 day'); 460 | } 461 | 462 | return $this->getDateWithTimezone( 463 | $dateTime->setTime($nextDateTime->format('G'), $nextDateTime->format('i'), 0), 464 | $outputTimezone 465 | ); 466 | } 467 | 468 | public function nextOpen( 469 | ?DateTimeInterface $dateTime = null, 470 | ?DateTimeInterface $searchUntil = null, 471 | ?DateTimeInterface $cap = null 472 | ): DateTimeInterface { 473 | $outputTimezone = $this->getOutputTimezone($dateTime); 474 | $dateTime = $this->applyTimezone($dateTime ?? new $this->dateTimeClass()); 475 | $dateTime = $this->copyDateTime($dateTime); 476 | $openingHoursForDay = $this->forDate($dateTime); 477 | $nextOpen = $openingHoursForDay->nextOpen(PreciseTime::fromDateTime($dateTime)); 478 | $tries = $this->getDayLimit(); 479 | 480 | while (! $nextOpen || $nextOpen->hours() >= 24) { 481 | if (--$tries < 0) { 482 | throw MaximumLimitExceeded::forString( 483 | 'No open date/time found in the next '.$this->getDayLimit().' days,'. 484 | ' use $openingHours->setDayLimit() to increase the limit.' 485 | ); 486 | } 487 | 488 | $dateTime = $dateTime 489 | ->modify('+1 day') 490 | ->setTime(0, 0, 0); 491 | 492 | if ($this->isOpenAt($dateTime) && ! $openingHoursForDay->isOpenAtTheEndOfTheDay()) { 493 | return $this->getDateWithTimezone($dateTime, $outputTimezone); 494 | } 495 | 496 | if ($cap && $dateTime > $cap) { 497 | return $cap; 498 | } 499 | 500 | if ($searchUntil && $dateTime > $searchUntil) { 501 | throw SearchLimitReached::forDate($searchUntil); 502 | } 503 | 504 | $openingHoursForDay = $this->forDate($dateTime); 505 | 506 | $nextOpen = $openingHoursForDay->nextOpen(PreciseTime::fromDateTime($dateTime)); 507 | } 508 | 509 | if ($dateTime->format(TimeDataContainer::TIME_FORMAT) === TimeDataContainer::MIDNIGHT && 510 | $this->isOpenAt($this->copyAndModify($dateTime, '-1 second')) 511 | ) { 512 | return $this->getDateWithTimezone( 513 | $this->nextOpen($dateTime->modify('+1 second')), 514 | $outputTimezone 515 | ); 516 | } 517 | 518 | $nextDateTime = $nextOpen->toDateTime(); 519 | 520 | return $this->getDateWithTimezone( 521 | $dateTime->setTime($nextDateTime->format('G'), $nextDateTime->format('i'), 0), 522 | $outputTimezone 523 | ); 524 | } 525 | 526 | public function nextClose( 527 | ?DateTimeInterface $dateTime = null, 528 | ?DateTimeInterface $searchUntil = null, 529 | ?DateTimeInterface $cap = null 530 | ): DateTimeInterface { 531 | $outputTimezone = $this->getOutputTimezone($dateTime); 532 | $dateTime = $this->applyTimezone($dateTime ?? new $this->dateTimeClass()); 533 | $dateTime = $this->copyDateTime($dateTime); 534 | $openRangeEnd = $this->currentOpenRange($dateTime)?->end(); 535 | 536 | if ($openRangeEnd && $openRangeEnd->hours() < 24) { 537 | return $openRangeEnd->date() ?? $openRangeEnd->toDateTime($dateTime); 538 | } 539 | 540 | $nextClose = null; 541 | 542 | if ($this->overflow) { 543 | $dateTimeMinus1Day = $this->yesterday($dateTime); 544 | $openingHoursForDayBefore = $this->forDate($dateTimeMinus1Day); 545 | 546 | if ($openingHoursForDayBefore->isOpenAtNight(PreciseTime::fromDateTime($dateTimeMinus1Day))) { 547 | $nextClose = $openingHoursForDayBefore->nextClose(PreciseTime::fromDateTime($dateTime)); 548 | } 549 | } 550 | 551 | $openingHoursForDay = $this->forDate($dateTime); 552 | 553 | if (! $nextClose) { 554 | $nextClose = $openingHoursForDay->nextClose(PreciseTime::fromDateTime($dateTime)); 555 | 556 | if ( 557 | $nextClose 558 | && $nextClose->hours() < 24 559 | && ( 560 | $nextClose->format('Gi') < $dateTime->format('Gi') 561 | || ($this->isClosedAt($dateTime) && $this->nextOpen($dateTime)->format('Gi') > $nextClose->format('Gi')) 562 | ) 563 | ) { 564 | $dateTime = $dateTime->modify('+1 day'); 565 | } 566 | } 567 | 568 | $tries = $this->getDayLimit(); 569 | 570 | while (! $nextClose || $nextClose->hours() >= 24) { 571 | if (--$tries < 0) { 572 | throw MaximumLimitExceeded::forString( 573 | 'No close date/time found in the next '.$this->getDayLimit().' days,'. 574 | ' use $openingHours->setDayLimit() to increase the limit.' 575 | ); 576 | } 577 | 578 | $dateTime = $dateTime 579 | ->modify('+1 day') 580 | ->setTime(0, 0, 0); 581 | 582 | if ($this->isClosedAt($dateTime) && $openingHoursForDay->isOpenAtTheEndOfTheDay()) { 583 | return $this->getDateWithTimezone($dateTime, $outputTimezone); 584 | } 585 | 586 | if ($cap && $dateTime > $cap) { 587 | return $cap; 588 | } 589 | 590 | if ($searchUntil && $dateTime > $searchUntil) { 591 | throw SearchLimitReached::forDate($searchUntil); 592 | } 593 | 594 | $openingHoursForDay = $this->forDate($dateTime); 595 | 596 | $nextClose = $openingHoursForDay->nextClose(PreciseTime::fromDateTime($dateTime)); 597 | } 598 | 599 | $nextDateTime = $nextClose->toDateTime(); 600 | 601 | return $this->getDateWithTimezone( 602 | $dateTime->setTime($nextDateTime->format('G'), $nextDateTime->format('i'), 0), 603 | $outputTimezone 604 | ); 605 | } 606 | 607 | public function previousOpen( 608 | DateTimeInterface $dateTime, 609 | ?DateTimeInterface $searchUntil = null, 610 | ?DateTimeInterface $cap = null 611 | ): DateTimeInterface { 612 | $outputTimezone = $this->getOutputTimezone($dateTime); 613 | $dateTime = $this->copyDateTime($this->applyTimezone($dateTime)); 614 | $openingHoursForDay = $this->forDate($dateTime); 615 | $previousOpen = $openingHoursForDay->previousOpen(PreciseTime::fromDateTime($dateTime)); 616 | $tries = $this->getDayLimit(); 617 | 618 | while (! $previousOpen || ($previousOpen->hours() === 0 && $previousOpen->minutes() === 0)) { 619 | if (--$tries < 0) { 620 | throw MaximumLimitExceeded::forString( 621 | 'No open date/time found in the previous '.$this->getDayLimit().' days,'. 622 | ' use $openingHours->setDayLimit() to increase the limit.' 623 | ); 624 | } 625 | 626 | $midnight = $dateTime->setTime(0, 0, 0); 627 | $dateTime = $this->copyAndModify($midnight, '-1 second'); 628 | 629 | $openingHoursForDay = $this->forDate($dateTime); 630 | 631 | if ($this->isOpenAt($midnight) && ! $openingHoursForDay->isOpenAtTheEndOfTheDay()) { 632 | return $this->getDateWithTimezone($midnight, $outputTimezone); 633 | } 634 | 635 | if ($cap && $dateTime < $cap) { 636 | return $cap; 637 | } 638 | 639 | if ($searchUntil && $dateTime < $searchUntil) { 640 | throw SearchLimitReached::forDate($searchUntil); 641 | } 642 | 643 | $previousOpen = $openingHoursForDay->previousOpen(PreciseTime::fromDateTime($dateTime)); 644 | } 645 | 646 | $nextDateTime = $previousOpen->toDateTime(); 647 | 648 | return $this->getDateWithTimezone( 649 | $dateTime->setTime($nextDateTime->format('G'), $nextDateTime->format('i'), 0), 650 | $outputTimezone 651 | ); 652 | } 653 | 654 | public function previousClose( 655 | DateTimeInterface $dateTime, 656 | ?DateTimeInterface $searchUntil = null, 657 | ?DateTimeInterface $cap = null 658 | ): DateTimeInterface { 659 | $outputTimezone = $this->getOutputTimezone($dateTime); 660 | $dateTime = $this->copyDateTime($this->applyTimezone($dateTime)); 661 | $previousClose = null; 662 | if ($this->overflow) { 663 | $dateTimeMinus1Day = $this->yesterday($dateTime); 664 | $openingHoursForDayBefore = $this->forDate($dateTimeMinus1Day); 665 | if ($openingHoursForDayBefore->isOpenAtNight(PreciseTime::fromDateTime($dateTimeMinus1Day))) { 666 | $previousClose = $openingHoursForDayBefore->previousClose(PreciseTime::fromDateTime($dateTime)); 667 | } 668 | } 669 | 670 | $openingHoursForDay = $this->forDate($dateTime); 671 | if (! $previousClose) { 672 | $previousClose = $openingHoursForDay->previousClose(PreciseTime::fromDateTime($dateTime)); 673 | } 674 | 675 | $tries = $this->getDayLimit(); 676 | 677 | while (! $previousClose || ($previousClose->hours() === 0 && $previousClose->minutes() === 0)) { 678 | if (--$tries < 0) { 679 | throw MaximumLimitExceeded::forString( 680 | 'No close date/time found in the previous '.$this->getDayLimit().' days,'. 681 | ' use $openingHours->setDayLimit() to increase the limit.' 682 | ); 683 | } 684 | 685 | $midnight = $dateTime->setTime(0, 0, 0); 686 | $dateTime = $this->copyAndModify($midnight, '-1 second'); 687 | $openingHoursForDay = $this->forDate($dateTime); 688 | 689 | if ($this->isClosedAt($midnight) && $openingHoursForDay->isOpenAtTheEndOfTheDay()) { 690 | return $this->getDateWithTimezone($midnight, $outputTimezone); 691 | } 692 | 693 | if ($cap && $dateTime < $cap) { 694 | return $cap; 695 | } 696 | 697 | if ($searchUntil && $dateTime < $searchUntil) { 698 | throw SearchLimitReached::forDate($searchUntil); 699 | } 700 | 701 | $previousClose = $openingHoursForDay->previousClose(PreciseTime::fromDateTime($dateTime)); 702 | } 703 | 704 | $previousDateTime = $previousClose->toDateTime(); 705 | 706 | return $this->getDateWithTimezone( 707 | $dateTime->setTime($previousDateTime->format('G'), $previousDateTime->format('i'), 0), 708 | $outputTimezone 709 | ); 710 | } 711 | 712 | public function regularClosingDays(): array 713 | { 714 | return array_keys($this->filter( 715 | static fn (OpeningHoursForDay $openingHoursForDay) => $openingHoursForDay->isEmpty(), 716 | )); 717 | } 718 | 719 | public function regularClosingDaysISO(): array 720 | { 721 | return array_map( 722 | static fn (string $dayName) => Day::from($dayName)->toISO(), 723 | $this->regularClosingDays(), 724 | ); 725 | } 726 | 727 | public function exceptionalClosingDates(): array 728 | { 729 | $dates = array_keys($this->filterExceptions( 730 | static fn (OpeningHoursForDay $openingHoursForDay) => $openingHoursForDay->isEmpty(), 731 | )); 732 | 733 | return Arr::map($dates, static fn ($date) => DateTime::createFromFormat('Y-m-d', $date)); 734 | } 735 | 736 | public function setTimezone(string|DateTimeZone|null $timezone): void 737 | { 738 | $this->timezone = $this->parseTimezone($timezone); 739 | } 740 | 741 | public function setOutputTimezone(string|DateTimeZone|null $timezone): void 742 | { 743 | $this->outputTimezone = $this->parseTimezone($timezone); 744 | } 745 | 746 | protected function parseOpeningHoursAndExceptions(array $data): array 747 | { 748 | $dateTimeClass = Arr::pull($data, 'dateTimeClass', null); 749 | $metaData = Arr::pull($data, 'data', null); 750 | $overflow = (bool) Arr::pull($data, 'overflow', false); 751 | [$exceptions, $filters] = $this->parseExceptions( 752 | Arr::pull($data, 'exceptions', []), 753 | Arr::pull($data, 'filters', []), 754 | ); 755 | $openingHours = $this->parseDaysOfWeeks($data); 756 | 757 | return [$openingHours, $exceptions, $metaData, $filters, $overflow, $dateTimeClass]; 758 | } 759 | 760 | protected function parseExceptions(array $data, array $filters): array 761 | { 762 | $exceptions = []; 763 | 764 | foreach ($data as $key => $exception) { 765 | if (is_callable($exception)) { 766 | $filters[] = $exception; 767 | 768 | continue; 769 | } 770 | 771 | foreach ($this->readDatesRange($key) as $date) { 772 | if (isset($exceptions[$date])) { 773 | throw InvalidDateRange::invalidDateRange($key, $date); 774 | } 775 | 776 | $exceptions[$date] = $exception; 777 | } 778 | } 779 | 780 | return [$exceptions, $filters]; 781 | } 782 | 783 | protected function parseDaysOfWeeks(array $data): array 784 | { 785 | $openingHours = []; 786 | 787 | foreach ($data as $dayKey => $openingHoursData) { 788 | foreach ($this->readDatesRange($dayKey) as $rawDay) { 789 | $day = $this->normalizeDayName($rawDay); 790 | 791 | if (isset($openingHours[$day])) { 792 | throw InvalidDateRange::invalidDateRange($dayKey, $day); 793 | } 794 | 795 | $openingHours[$day] = $openingHoursData; 796 | } 797 | } 798 | 799 | return $openingHours; 800 | } 801 | 802 | protected function readDatesRange(Day|string $key): iterable 803 | { 804 | if ($key instanceof Day) { 805 | return [$key->value]; 806 | } 807 | 808 | $toChunks = preg_split('/\sto\s/', $key, 2); 809 | 810 | if (count($toChunks) === 2) { 811 | return $this->daysBetween(trim($toChunks[0]), trim($toChunks[1])); 812 | } 813 | 814 | $dashChunks = explode('-', $key); 815 | $chunksCount = count($dashChunks); 816 | $firstChunk = trim($dashChunks[0]); 817 | 818 | if ($chunksCount === 2 && preg_match('/^[A-Za-z]+$/', $firstChunk)) { 819 | return $this->daysBetween($firstChunk, trim($dashChunks[1])); 820 | } 821 | 822 | if ($chunksCount >= 4) { 823 | $middle = ceil($chunksCount / 2); 824 | 825 | return $this->daysBetween( 826 | trim(implode('-', array_slice($dashChunks, 0, $middle))), 827 | trim(implode('-', array_slice($dashChunks, $middle))), 828 | ); 829 | } 830 | 831 | return [$key]; 832 | } 833 | 834 | /** @return Generator */ 835 | protected function daysBetween(string $start, string $end): Generator 836 | { 837 | $count = count(explode('-', $start)); 838 | 839 | if ($count === 2) { 840 | // Use an arbitrary leap year 841 | $start = "2024-$start"; 842 | $end = "2024-$end"; 843 | } 844 | 845 | $startDate = new DateTimeImmutable($start); 846 | $endDate = $startDate->modify($end)->modify('+12 hours'); 847 | 848 | $format = [ 849 | 2 => 'm-d', 850 | 3 => 'Y-m-d', 851 | ][$count] ?? 'l'; 852 | 853 | foreach (new DatePeriod($startDate, new DateInterval('P1D'), $endDate) as $date) { 854 | yield $date->format($format); 855 | } 856 | } 857 | 858 | protected function getOpeningHoursFromStrings(array $openingHours): OpeningHoursForDay 859 | { 860 | $data = $openingHours['data'] ?? null; 861 | unset($openingHours['data']); 862 | 863 | return OpeningHoursForDay::fromStrings($openingHours, $data); 864 | } 865 | 866 | protected function setExceptionsFromStrings(array $exceptions): void 867 | { 868 | if ($exceptions === []) { 869 | return; 870 | } 871 | 872 | if (! $this->dayLimit) { 873 | $this->dayLimit = 366; 874 | } 875 | 876 | $this->exceptions = Arr::map($exceptions, static function (array $openingHours, string $date) { 877 | $recurring = DateTime::createFromFormat('m-d', $date); 878 | 879 | if ($recurring === false || $recurring->format('m-d') !== $date) { 880 | $dateTime = DateTime::createFromFormat('Y-m-d', $date); 881 | 882 | if ($dateTime === false || $dateTime->format('Y-m-d') !== $date) { 883 | throw InvalidDate::invalidDate($date); 884 | } 885 | } 886 | 887 | return OpeningHoursForDay::fromStrings($openingHours); 888 | }); 889 | } 890 | 891 | protected function normalizeDayName(Day|string $day): string 892 | { 893 | return (is_string($day) ? Day::fromName($day) : $day)->value; 894 | } 895 | 896 | protected function applyTimezone(DateTimeInterface $date): DateTimeInterface 897 | { 898 | return $this->getDateWithTimezone($date, $this->timezone); 899 | } 900 | 901 | protected function getDateWithTimezone(DateTimeInterface $date, ?DateTimeZone $timezone): DateTimeInterface 902 | { 903 | if ($timezone) { 904 | if ($date instanceof DateTime) { 905 | $date = clone $date; 906 | } 907 | 908 | $date = $date->setTimezone($timezone); 909 | } 910 | 911 | return $date; 912 | } 913 | 914 | /** 915 | * Returns opening hours for the days that match a given condition as an array. 916 | * 917 | * @return OpeningHoursForDay[] 918 | */ 919 | public function filter(callable $callback): array 920 | { 921 | return Arr::filter($this->openingHours, $callback); 922 | } 923 | 924 | public function map(callable $callback): array 925 | { 926 | return Arr::map($this->openingHours, $callback); 927 | } 928 | 929 | public function flatMap(callable $callback): array 930 | { 931 | return Arr::flatMap($this->openingHours, $callback); 932 | } 933 | 934 | /** 935 | * Returns opening hours for the exceptions that match a given condition as an array. 936 | * 937 | * @return OpeningHoursForDay[] 938 | */ 939 | public function filterExceptions(callable $callback): array 940 | { 941 | return Arr::filter($this->exceptions, $callback); 942 | } 943 | 944 | /** Checks that all exceptions match a given condition */ 945 | public function everyExceptions(callable $callback): bool 946 | { 947 | return $this->filterExceptions( 948 | static fn (OpeningHoursForDay $day) => ! $callback($day), 949 | ) === []; 950 | } 951 | 952 | public function mapExceptions(callable $callback): array 953 | { 954 | return Arr::map($this->exceptions, $callback); 955 | } 956 | 957 | public function flatMapExceptions(callable $callback): array 958 | { 959 | return Arr::flatMap($this->exceptions, $callback); 960 | } 961 | 962 | /** Checks that opening hours for every day of the week matches a given condition */ 963 | public function every(callable $callback): bool 964 | { 965 | return $this->filter( 966 | static fn (OpeningHoursForDay $day) => ! $callback($day), 967 | ) === []; 968 | } 969 | 970 | public function asStructuredData( 971 | string $format = TimeDataContainer::TIME_FORMAT, 972 | DateTimeZone|string|null $timezone = null, 973 | ): array { 974 | $regularHours = $this->flatMap( 975 | static fn (OpeningHoursForDay $openingHoursForDay, string $day) => $openingHoursForDay->map( 976 | static fn (TimeRange $timeRange) => [ 977 | '@type' => 'OpeningHoursSpecification', 978 | 'dayOfWeek' => ucfirst($day), 979 | 'opens' => $timeRange->start()->format($format, $timezone), 980 | 'closes' => $timeRange->end()->format($format, $timezone), 981 | ], 982 | ), 983 | ); 984 | 985 | $exceptions = $this->flatMapExceptions( 986 | static function (OpeningHoursForDay $openingHoursForDay, string $date) use ($format, $timezone) { 987 | if ($openingHoursForDay->isEmpty()) { 988 | $zero = Time::fromString(TimeDataContainer::MIDNIGHT)->format($format, $timezone); 989 | 990 | return [[ 991 | '@type' => 'OpeningHoursSpecification', 992 | 'opens' => $zero, 993 | 'closes' => $zero, 994 | 'validFrom' => $date, 995 | 'validThrough' => $date, 996 | ]]; 997 | } 998 | 999 | return $openingHoursForDay->map( 1000 | static fn (TimeRange $timeRange) => [ 1001 | '@type' => 'OpeningHoursSpecification', 1002 | 'opens' => $timeRange->start()->format($format, $timezone), 1003 | 'closes' => $timeRange->end()->format($format, $timezone), 1004 | 'validFrom' => $date, 1005 | 'validThrough' => $date, 1006 | ], 1007 | ); 1008 | }, 1009 | ); 1010 | 1011 | return array_merge($regularHours, $exceptions); 1012 | } 1013 | 1014 | public function isAlwaysClosed(): bool 1015 | { 1016 | $isAlwaysClosedCallback = static fn (OpeningHoursForDay $day) => $day->isEmpty(); 1017 | $allExceptionsAlwaysClosed = $this->everyExceptions($isAlwaysClosedCallback); 1018 | $allOpeningHoursAlwaysClosed = $this->every($isAlwaysClosedCallback); 1019 | $noFiltersApplied = $this->filters === []; 1020 | 1021 | return $allExceptionsAlwaysClosed && $noFiltersApplied && $allOpeningHoursAlwaysClosed; 1022 | } 1023 | 1024 | public function isAlwaysOpen(): bool 1025 | { 1026 | $isAlwaysOpenCallback = static fn (OpeningHoursForDay $day) => ((string) $day) === '00:00-24:00'; 1027 | $allExceptionsAlwaysOpen = $this->everyExceptions($isAlwaysOpenCallback); 1028 | $allOpeningHoursAlwaysOpen = $this->every($isAlwaysOpenCallback); 1029 | $noFiltersApplied = $this->filters === []; 1030 | 1031 | return $allExceptionsAlwaysOpen && $noFiltersApplied && $allOpeningHoursAlwaysOpen; 1032 | } 1033 | 1034 | private static function filterHours(array $data, array $excludedKeys): Generator 1035 | { 1036 | foreach ($data as $key => $value) { 1037 | if (in_array($key, $excludedKeys, true)) { 1038 | continue; 1039 | } 1040 | 1041 | if (is_int($key) && is_array($value) && isset($value['hours'])) { 1042 | foreach ((array) $value['hours'] as $subKey => $hour) { 1043 | yield "$key.$subKey" => [$hour, $value['data'] ?? null]; 1044 | } 1045 | 1046 | continue; 1047 | } 1048 | 1049 | yield $key => [$value, null]; 1050 | } 1051 | } 1052 | 1053 | private function parseTimezone(mixed $timezone): ?DateTimeZone 1054 | { 1055 | if ($timezone instanceof DateTimeZone) { 1056 | return $timezone; 1057 | } 1058 | 1059 | if (is_string($timezone)) { 1060 | return new DateTimeZone($timezone); 1061 | } 1062 | 1063 | if ($timezone) { 1064 | throw InvalidTimezone::create(); 1065 | } 1066 | 1067 | return null; 1068 | } 1069 | 1070 | private function getOutputTimezone(?DateTimeInterface $dateTime = null): ?DateTimeZone 1071 | { 1072 | if ($this->outputTimezone !== null) { 1073 | return $this->outputTimezone; 1074 | } 1075 | 1076 | if ($this->timezone === null || $dateTime === null) { 1077 | return $this->timezone; 1078 | } 1079 | 1080 | return $dateTime->getTimezone(); 1081 | } 1082 | } 1083 | -------------------------------------------------------------------------------- /src/OpeningHoursForDay.php: -------------------------------------------------------------------------------- 1 | guardAgainstTimeRangeOverlaps($openingHours); 27 | } 28 | 29 | public static function fromStrings(array $strings, mixed $data = null): static 30 | { 31 | if (isset($strings['hours'])) { 32 | return static::fromStrings($strings['hours'], $strings['data'] ?? $data); 33 | } 34 | 35 | $data ??= $strings['data'] ?? null; 36 | unset($strings['data']); 37 | 38 | uasort($strings, static fn ($a, $b) => strcmp(static::getHoursFromRange($a), static::getHoursFromRange($b))); 39 | 40 | return new static( 41 | Arr::map($strings, static fn ($string) => $string instanceof TimeRange ? $string : TimeRange::fromDefinition($string)), 42 | $data, 43 | ); 44 | } 45 | 46 | public function isOpenAt(Time $time): bool 47 | { 48 | foreach ($this->openingHours as $timeRange) { 49 | if ($timeRange->containsTime($time)) { 50 | return true; 51 | } 52 | } 53 | 54 | return false; 55 | } 56 | 57 | public function isOpenAtTheEndOfTheDay(): bool 58 | { 59 | return $this->isOpenAt(Time::fromString('23:59')); 60 | } 61 | 62 | public function isOpenAtNight(Time $time): bool 63 | { 64 | foreach ($this->openingHours as $timeRange) { 65 | if ($timeRange->containsNightTime($time)) { 66 | return true; 67 | } 68 | } 69 | 70 | return false; 71 | } 72 | 73 | /** 74 | * @param callable[] $filters 75 | * @param bool $reverse 76 | * @return Time|TimeRange|null 77 | */ 78 | public function openingHoursFilter(array $filters, bool $reverse = false): ?TimeDataContainer 79 | { 80 | foreach (($reverse ? array_reverse($this->openingHours) : $this->openingHours) as $timeRange) { 81 | foreach ($filters as $filter) { 82 | if ($result = $filter($timeRange)) { 83 | return $result; 84 | } 85 | } 86 | } 87 | 88 | return null; 89 | } 90 | 91 | /** 92 | * @param Time $time 93 | * @return Time|null 94 | */ 95 | public function nextOpen(Time $time): ?Time 96 | { 97 | return $this->openingHoursFilter([ 98 | fn ($timeRange) => $this->findOpenInFreeTime($time, $timeRange), 99 | ]); 100 | } 101 | 102 | /** 103 | * @param Time $time 104 | * @return TimeRange|null 105 | */ 106 | public function nextOpenRange(Time $time): ?TimeRange 107 | { 108 | return $this->openingHoursFilter([ 109 | fn ($timeRange) => $this->findRangeInFreeTime($time, $timeRange), 110 | ]); 111 | } 112 | 113 | /** 114 | * @param Time $time 115 | * @return Time|null 116 | */ 117 | public function nextClose(Time $time): ?Time 118 | { 119 | return $this->openingHoursFilter([ 120 | fn ($timeRange) => $this->findCloseInWorkingHours($time, $timeRange), 121 | fn ($timeRange) => $this->findCloseInFreeTime($time, $timeRange), 122 | ]); 123 | } 124 | 125 | /** 126 | * @param Time $time 127 | * @return TimeRange|null 128 | */ 129 | public function nextCloseRange(Time $time): ?TimeRange 130 | { 131 | return $this->openingHoursFilter([ 132 | fn ($timeRange) => $this->findCloseRangeInWorkingHours($time, $timeRange), 133 | fn ($timeRange) => $this->findRangeInFreeTime($time, $timeRange), 134 | ]); 135 | } 136 | 137 | /** 138 | * @param Time $time 139 | * @return Time|null 140 | */ 141 | public function previousOpen(Time $time): ?Time 142 | { 143 | return $this->openingHoursFilter([ 144 | fn ($timeRange) => $this->findPreviousOpenInFreeTime($time, $timeRange), 145 | fn ($timeRange) => $this->findOpenInWorkingHours($time, $timeRange), 146 | ], true); 147 | } 148 | 149 | /** 150 | * @param Time $time 151 | * @return TimeRange|null 152 | */ 153 | public function previousOpenRange(Time $time): ?TimeRange 154 | { 155 | return $this->openingHoursFilter([ 156 | fn ($timeRange) => $this->findRangeInFreeTime($time, $timeRange), 157 | ], true); 158 | } 159 | 160 | /** 161 | * @param Time $time 162 | * @return Time|null 163 | */ 164 | public function previousClose(Time $time): ?Time 165 | { 166 | return $this->openingHoursFilter([ 167 | fn ($timeRange) => $this->findPreviousCloseInWorkingHours($time, $timeRange), 168 | ], true); 169 | } 170 | 171 | /** 172 | * @param Time $time 173 | * @return TimeRange|null 174 | */ 175 | public function previousCloseRange(Time $time): ?TimeRange 176 | { 177 | return $this->openingHoursFilter([ 178 | fn ($timeRange) => $this->findPreviousRangeInFreeTime($time, $timeRange), 179 | ], true); 180 | } 181 | 182 | protected static function getHoursFromRange($range): string 183 | { 184 | return strval((is_array($range) 185 | ? ($range['hours'] ?? array_values($range)[0] ?? null) 186 | : null 187 | ) ?: $range); 188 | } 189 | 190 | public function offsetExists($offset): bool 191 | { 192 | return isset($this->openingHours[$offset]); 193 | } 194 | 195 | public function offsetGet($offset): TimeRange 196 | { 197 | return $this->openingHours[$offset]; 198 | } 199 | 200 | public function offsetSet($offset, $value): void 201 | { 202 | throw NonMutableOffsets::forClass(static::class); 203 | } 204 | 205 | public function offsetUnset($offset): void 206 | { 207 | throw NonMutableOffsets::forClass(static::class); 208 | } 209 | 210 | public function count(): int 211 | { 212 | return count($this->openingHours); 213 | } 214 | 215 | public function getIterator(): ArrayIterator 216 | { 217 | return new ArrayIterator($this->openingHours); 218 | } 219 | 220 | /** 221 | * @param Time $time 222 | * @return TimeRange[] 223 | */ 224 | public function forTime(Time $time): Generator 225 | { 226 | foreach ($this as $range) { 227 | /* @var TimeRange $range */ 228 | 229 | if ($range->containsTime($time)) { 230 | yield $range; 231 | } 232 | } 233 | } 234 | 235 | /** 236 | * @param Time $time 237 | * @return TimeRange[] 238 | */ 239 | public function forNightTime(Time $time): Generator 240 | { 241 | foreach ($this as $range) { 242 | /* @var TimeRange $range */ 243 | 244 | if ($range->containsNightTime($time)) { 245 | yield $range; 246 | } 247 | } 248 | } 249 | 250 | public function isEmpty(): bool 251 | { 252 | return $this->openingHours === []; 253 | } 254 | 255 | public function map(callable $callback): array 256 | { 257 | return Arr::map($this->openingHours, $callback); 258 | } 259 | 260 | protected function guardAgainstTimeRangeOverlaps(array $openingHours): void 261 | { 262 | foreach (Arr::createUniquePairs($openingHours) as $timeRanges) { 263 | if ($timeRanges[0]->overlaps($timeRanges[1])) { 264 | throw OverlappingTimeRanges::forRanges($timeRanges[0], $timeRanges[1]); 265 | } 266 | } 267 | } 268 | 269 | public function __toString(): string 270 | { 271 | $values = []; 272 | 273 | foreach ($this->openingHours as $openingHour) { 274 | $values[] = (string) $openingHour; 275 | } 276 | 277 | return implode(',', $values); 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/OpeningHoursSpecificationParser.php: -------------------------------------------------------------------------------- 1 | $openingHoursSpecificationItem) { 17 | try { 18 | $this->parseOpeningHoursSpecificationItem($openingHoursSpecificationItem); 19 | } catch (InvalidOpeningHoursSpecification $exception) { 20 | $message = $exception->getMessage(); 21 | 22 | throw new InvalidOpeningHoursSpecification( 23 | "Invalid openingHoursSpecification item at index $index: $message", 24 | previous: $exception, 25 | ); 26 | } 27 | } 28 | } 29 | 30 | public static function createFromArray(array $openingHoursSpecification): self 31 | { 32 | return new self($openingHoursSpecification); 33 | } 34 | 35 | public static function createFromString(string $openingHoursSpecification): self 36 | { 37 | try { 38 | return self::createFromArray(json_decode( 39 | $openingHoursSpecification, 40 | true, 41 | flags: JSON_THROW_ON_ERROR, 42 | )); 43 | } catch (JsonException $e) { 44 | throw new InvalidOpeningHoursSpecification( 45 | 'Invalid https://schema.org/OpeningHoursSpecification JSON', 46 | previous: $e, 47 | ); 48 | } 49 | } 50 | 51 | public static function create(array|string $openingHoursSpecification): self 52 | { 53 | return is_string($openingHoursSpecification) 54 | ? self::createFromString($openingHoursSpecification) 55 | : self::createFromArray($openingHoursSpecification); 56 | } 57 | 58 | public function getOpeningHours(): array 59 | { 60 | return $this->openingHours; 61 | } 62 | 63 | /** 64 | * Regular opening hours. 65 | */ 66 | private function addDaysOfWeek( 67 | array $dayOfWeek, 68 | mixed $opens, 69 | mixed $closes, 70 | ): void { 71 | // Multiple days of week for same specification 72 | foreach ($dayOfWeek as $dayOfWeekItem) { 73 | if (! is_string($dayOfWeekItem)) { 74 | throw new InvalidOpeningHoursSpecification( 75 | 'Invalid https://schema.org/OpeningHoursSpecification dayOfWeek', 76 | ); 77 | } 78 | 79 | $this->addDayOfWeekHours($dayOfWeekItem, $opens, $closes); 80 | } 81 | } 82 | 83 | private function schemaOrgDayToString(string $schemaOrgDaySpec): string 84 | { 85 | // Support official and Google-flavored Day specifications 86 | return match ($schemaOrgDaySpec) { 87 | 'Monday', 'https://schema.org/Monday' => 'monday', 88 | 'Tuesday', 'https://schema.org/Tuesday' => 'tuesday', 89 | 'Wednesday', 'https://schema.org/Wednesday' => 'wednesday', 90 | 'Thursday', 'https://schema.org/Thursday' => 'thursday', 91 | 'Friday', 'https://schema.org/Friday' => 'friday', 92 | 'Saturday', 'https://schema.org/Saturday' => 'saturday', 93 | 'Sunday', 'https://schema.org/Sunday' => 'sunday', 94 | 'PublicHolidays', 'https://schema.org/PublicHolidays' => throw new InvalidOpeningHoursSpecification( 95 | 'PublicHolidays not supported', 96 | ), 97 | default => throw new InvalidOpeningHoursSpecification( 98 | 'Invalid https://schema.org Day specification', 99 | ), 100 | }; 101 | } 102 | 103 | private function addDayOfWeekHours( 104 | string $dayOfWeek, 105 | mixed $opens, 106 | mixed $closes, 107 | ): void { 108 | $dayOfWeek = self::schemaOrgDayToString($dayOfWeek); 109 | 110 | $hours = $this->formatHours($opens, $closes); 111 | 112 | if ($hours === null) { 113 | return; 114 | } 115 | 116 | $this->openingHours[$dayOfWeek][] = $hours; 117 | } 118 | 119 | private function addExceptionsHours( 120 | string $validFrom, 121 | string $validThrough, 122 | mixed $opens, 123 | mixed $closes, 124 | ): void { 125 | if (! preg_match('/^(?:\d{4}-)?\d{2}-\d{2}$/', $validFrom)) { 126 | throw new InvalidOpeningHoursSpecification('Invalid validFrom date'); 127 | } 128 | 129 | if (! preg_match('/^(?:\d{4}-)?\d{2}-\d{2}$/', $validThrough)) { 130 | throw new InvalidOpeningHoursSpecification('Invalid validThrough date'); 131 | } 132 | 133 | $exceptionKey = $validFrom === $validThrough ? $validFrom : $validFrom.' to '.$validThrough; 134 | 135 | $this->openingHours['exceptions'] ??= []; 136 | // Default to close all day 137 | $this->openingHours['exceptions'][$exceptionKey] ??= []; 138 | 139 | $hours = $this->formatHours($opens, $closes); 140 | 141 | if ($hours === null) { 142 | return; 143 | } 144 | 145 | $this->openingHours['exceptions'][$exceptionKey][] = $hours; 146 | } 147 | 148 | private function formatHours(mixed $opens, mixed $closes): ?string 149 | { 150 | if ($opens === null) { 151 | if ($closes !== null) { 152 | throw new InvalidOpeningHoursSpecification( 153 | 'Property opens and closes must be both null or both string', 154 | ); 155 | } 156 | 157 | return null; 158 | } 159 | 160 | if (! is_string($opens) || ! preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $opens)) { 161 | throw new InvalidOpeningHoursSpecification('Invalid opens hour'); 162 | } 163 | 164 | if (! is_string($closes) || ! preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $closes)) { 165 | throw new InvalidOpeningHoursSpecification('Invalid closes hours'); 166 | } 167 | 168 | // strip seconds part if present 169 | $opens = preg_replace('/^(\d{2}:\d{2})(:\d{2})?$/', '$1', $opens); 170 | $closes = preg_replace('/^(\d{2}:\d{2})(:\d{2})?$/', '$1', $closes); 171 | 172 | // Ignore 00:00-00:00 which means closed all day 173 | if ($opens === '00:00' && $closes === '00:00') { 174 | return null; 175 | } 176 | 177 | return $opens.'-'.($closes === '23:59' ? '24:00' : $closes); 178 | } 179 | 180 | private function parseOpeningHoursSpecificationItem(mixed $openingHoursSpecificationItem): void 181 | { 182 | // extract $openingHoursSpecificationItem keys into variables 183 | [ 184 | 'dayOfWeek' => $dayOfWeek, 185 | 'validFrom' => $validFrom, 186 | 'validThrough' => $validThrough, 187 | 'opens' => $opens, 188 | 'closes' => $closes, 189 | ] = array_merge([ 190 | // Default values: 191 | 'dayOfWeek' => null, 192 | 'validFrom' => null, 193 | 'validThrough' => null, 194 | 'opens' => null, 195 | 'closes' => null, 196 | ], $openingHoursSpecificationItem); 197 | 198 | if ($dayOfWeek !== null) { 199 | if (is_string($dayOfWeek)) { 200 | $dayOfWeek = [$dayOfWeek]; 201 | } 202 | 203 | if (! is_array($dayOfWeek)) { 204 | throw new InvalidOpeningHoursSpecification( 205 | 'Property dayOfWeek must be a string or an array of strings', 206 | ); 207 | } 208 | 209 | $this->addDaysOfWeek($dayOfWeek, $opens, $closes); 210 | 211 | return; 212 | } 213 | 214 | if (! is_string($validFrom) || ! is_string($validThrough)) { 215 | throw new InvalidOpeningHoursSpecification( 216 | 'Contains neither dayOfWeek nor validFrom and validThrough dates', 217 | ); 218 | } 219 | 220 | /* 221 | * Exception opening hours 222 | */ 223 | $this->addExceptionsHours($validFrom, $validThrough, $opens, $closes); 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/PreciseTime.php: -------------------------------------------------------------------------------- 1 | dateTime->format('G'); 32 | } 33 | 34 | public function minutes(): int 35 | { 36 | return (int) $this->dateTime->format('i'); 37 | } 38 | 39 | public static function fromDateTime(DateTimeInterface $dateTime, mixed $data = null): self 40 | { 41 | return new self($dateTime, $data); 42 | } 43 | 44 | public function isSame(parent $time): bool 45 | { 46 | return $this->format('H:i:s.u') === $time->format('H:i:s.u'); 47 | } 48 | 49 | public function isAfter(parent $time): bool 50 | { 51 | return $this->format('H:i:s.u') > $time->format('H:i:s.u'); 52 | } 53 | 54 | public function isBefore(parent $time): bool 55 | { 56 | return $this->format('H:i:s.u') < $time->format('H:i:s.u'); 57 | } 58 | 59 | public function isSameOrAfter(parent $time): bool 60 | { 61 | return $this->format('H:i:s.u') >= $time->format('H:i:s.u'); 62 | } 63 | 64 | public function diff(parent $time): DateInterval 65 | { 66 | return $this->toDateTime()->diff($time->toDateTime()); 67 | } 68 | 69 | public function toDateTime(?DateTimeInterface $date = null): DateTimeInterface 70 | { 71 | return $date 72 | ? $this->copyDateTime($date)->modify($this->format('H:i:s.u')) 73 | : $this->copyDateTime($this->dateTime); 74 | } 75 | 76 | public function format(string $format = 'H:i', DateTimeZone|string|null $timezone = null): string 77 | { 78 | $date = $timezone 79 | ? $this->copyDateTime($this->dateTime)->setTimezone($timezone instanceof DateTimeZone 80 | ? $timezone 81 | : new DateTimeZone($timezone) 82 | ) 83 | : $this->dateTime; 84 | 85 | return $date->format($format); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Time.php: -------------------------------------------------------------------------------- 1 | hours; 41 | } 42 | 43 | public function minutes(): int 44 | { 45 | return $this->minutes; 46 | } 47 | 48 | public function date(): ?DateTimeInterface 49 | { 50 | return $this->date; 51 | } 52 | 53 | public static function fromDateTime(DateTimeInterface $dateTime, mixed $data = null): self 54 | { 55 | return static::fromString($dateTime->format(self::TIME_FORMAT), $data); 56 | } 57 | 58 | public function isSame(self $time): bool 59 | { 60 | return $this->hours === $time->hours && $this->minutes === $time->minutes; 61 | } 62 | 63 | public function isAfter(self $time): bool 64 | { 65 | return $this > $time; 66 | } 67 | 68 | public function isBefore(self $time): bool 69 | { 70 | return $this < $time; 71 | } 72 | 73 | public function isSameOrAfter(self $time): bool 74 | { 75 | return $this->isSame($time) || $this->isAfter($time); 76 | } 77 | 78 | public function diff(self $time): DateInterval 79 | { 80 | return $this->toDateTime()->diff($time->toDateTime()); 81 | } 82 | 83 | public function toDateTime(?DateTimeInterface $date = null): DateTimeInterface 84 | { 85 | $date = $date ? $this->copyDateTime($date) : new DateTime('1970-01-01 00:00:00'); 86 | 87 | return $date->setTime($this->hours, $this->minutes); 88 | } 89 | 90 | public function format(string $format = self::TIME_FORMAT, DateTimeZone|string|null $timezone = null): string 91 | { 92 | $date = $this->date ?: ($timezone 93 | ? new DateTimeImmutable('1970-01-01 00:00:00', $timezone instanceof DateTimeZone 94 | ? $timezone 95 | : new DateTimeZone($timezone) 96 | ) 97 | : null 98 | ); 99 | 100 | if ($this->hours === 24 && $this->minutes === 0 && substr($format, 0, 3) === self::TIME_FORMAT) { 101 | return '24:00'.$this->formatSecond($format, $date); 102 | } 103 | 104 | return $this->toDateTime($date)->format($format); 105 | } 106 | 107 | public function __toString(): string 108 | { 109 | return $this->format(); 110 | } 111 | 112 | private function formatSecond(string $format, ?DateTimeImmutable $date = null): string 113 | { 114 | return strlen($format) > 3 115 | ? ($date ?? new DateTimeImmutable('1970-01-01 00:00:00'))->format(substr($format, 3)) 116 | : ''; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/TimeDataContainer.php: -------------------------------------------------------------------------------- 1 | start; 79 | $end = $ranges[0]->end; 80 | 81 | foreach (array_slice($ranges, 1) as $range) { 82 | if ($range->start->isBefore($start)) { 83 | $start = $range->start; 84 | } 85 | 86 | if ($range->end->isAfter($end)) { 87 | $end = $range->end; 88 | } 89 | } 90 | 91 | return new self($start, $end, $data); 92 | } 93 | 94 | public static function fromMidnight(Time $end, $data = null): self 95 | { 96 | return new self(Time::fromString(self::MIDNIGHT), $end, $data); 97 | } 98 | 99 | public function start(): Time 100 | { 101 | return $this->start; 102 | } 103 | 104 | public function end(): Time 105 | { 106 | return $this->end; 107 | } 108 | 109 | public function isReversed(): bool 110 | { 111 | return $this->start->isAfter($this->end); 112 | } 113 | 114 | public function overflowsNextDay(): bool 115 | { 116 | return $this->isReversed(); 117 | } 118 | 119 | public function spillsOverToNextDay(): bool 120 | { 121 | return $this->isReversed(); 122 | } 123 | 124 | public function containsTime(Time $time): bool 125 | { 126 | return $time->isSameOrAfter($this->start) && ($this->overflowsNextDay() || $time->isBefore($this->end)); 127 | } 128 | 129 | public function containsNightTime(Time $time): bool 130 | { 131 | return $this->overflowsNextDay() && self::fromMidnight($this->end)->containsTime($time); 132 | } 133 | 134 | public function overlaps(self $timeRange): bool 135 | { 136 | return $this->containsTime($timeRange->start) || $this->containsTime($timeRange->end); 137 | } 138 | 139 | public function format(string $timeFormat = self::TIME_FORMAT, string $rangeFormat = '%s-%s', $timezone = null): string 140 | { 141 | return sprintf($rangeFormat, $this->start->format($timeFormat, $timezone), $this->end->format($timeFormat, $timezone)); 142 | } 143 | 144 | public function __toString(): string 145 | { 146 | return $this->format(); 147 | } 148 | } 149 | --------------------------------------------------------------------------------