The opening hours on a small business website are usually a line of text in the footer: "Mon to Fri 8 to 19, Sat 9 to 14". It is right most of the year. It is wrong on 3 March, at Easter, over Christmas, on the Monday that replaces a holiday that fell on a Saturday, and every day at lunch if the shop closes from 13:00 to 14:00. Customers who drive over and find the door locked do not come back, and search engines repeat whatever the page says. This guide shows how to model real opening hours in WordPress, include the Bulgarian holidays, show an "open now" line that is right even from a cached page, and give search engines the same information as schema.org data. All code samples were run on PHP 8.4 and Node.
#Model the hours as intervals, not text
Everything else depends on this. A day is a list of time ranges, not a string: two ranges for a lunch break, an empty list for a closed day, and a range whose end is earlier than its start for a bar that closes after midnight.
<?php
$week = [
'mon' => [['08:00', '13:00'], ['14:00', '19:00']],
'tue' => [['08:00', '13:00'], ['14:00', '19:00']],
'wed' => [['08:00', '13:00'], ['14:00', '19:00']],
'thu' => [['08:00', '13:00'], ['14:00', '19:00']],
'fri' => [['08:00', '13:00'], ['14:00', '19:00']],
'sat' => [['09:00', '14:00']],
'sun' => [],
];
function weekly_spec(array $week): array
{
$names = ['mon' => 'Monday', 'tue' => 'Tuesday', 'wed' => 'Wednesday', 'thu' => 'Thursday',
'fri' => 'Friday', 'sat' => 'Saturday', 'sun' => 'Sunday'];
$groups = [];
foreach ($names as $key => $name) {
foreach ($week[$key] ?? [] as [$opens, $closes]) {
$groups["$opens-$closes"][] = $name;
}
}
$out = [];
foreach ($groups as $span => $days) {
[$opens, $closes] = explode('-', $span);
$out[] = ['@type' => 'OpeningHoursSpecification', 'dayOfWeek' => $days,
'opens' => $opens, 'closes' => $opens === '00:00' && $closes === '00:00' ? '23:59' : $closes];
}
return $out;
}
weekly_spec() turns the week into schema.org OpeningHoursSpecification entries, grouping days that share a range. For the bakery above it gives two entries for Monday to Friday (morning and afternoon) and one for Saturday; Sunday is simply missing, which means closed. The function follows the rules in Google's documentation for local businesses: a night that runs past midnight stays one entry (Friday, opens 18:00, closes 02:00), and a day open around the clock is written 00:00 to 23:59.
#Holidays: decide a policy, then let dates win
The Bulgarian calendar has fixed holidays, Easter holidays that follow the Orthodox date, days off that replace a holiday falling on a Saturday or Sunday (Labour Code, article 154(2)), and days the government declares. We explained those rules in our guide to Bulgarian working days and holidays. For example, in 2026, 26 December falls on a Saturday, so Monday 28 December is a day off.
The calendar tells you which days are holidays. Whether your business closes is your decision, and it differs by business: a bakery may close on Christmas Day but work a short morning on Christmas Eve; a pharmacy may keep its usual hours; a restaurant may open longer. So keep a policy for public holidays (closed, usual hours, or holiday hours) and a list of your own dates that always wins:
<?php
// $policy for public holidays: 'closed', 'usual' or a list of intervals. Your own dates always win.
function special_days(array $holidays, string|array $policy, array $own, string $from, int $days): array
{
$out = [];
$day = new DateTimeImmutable($from);
for ($i = 0; $i < $days; $i++, $day = $day->modify('+1 day')) {
$date = $day->format('Y-m-d');
if (array_key_exists($date, $own)) {
$out[$date] = $own[$date];
} elseif (isset($holidays[$date]) && $policy !== 'usual') {
$out[$date] = $policy === 'closed' ? [] : $policy;
}
}
return $out;
}
function special_spec(array $special): array
{
$out = [];
foreach ($special as $date => $intervals) {
foreach ($intervals ?: [['00:00', '00:00']] as [$opens, $closes]) {
$out[] = ['@type' => 'OpeningHoursSpecification', 'validFrom' => $date,
'validThrough' => $date, 'opens' => $opens, 'closes' => $closes];
}
}
return $out;
}
For our bakery, with the policy "closed" and a short day of its own on 24 December from 10:00 to 14:00, the next sixty days from 20 December 2026 produce five special entries: 24 December from 10:00 to 14:00, and 25, 26 and 28 December and 1 January closed, written as opens and closes 00:00, which is how Google's documentation marks a day closed all day. These go into specialOpeningHoursSpecification, the schema.org property for exceptions that override the regular hours.
Two practical points. Generate the special hours for a window ahead (sixty days is reasonable) and regenerate them daily, so the data never runs out. And when the government declares an extra day off after you set up the site, nothing will know about it: add it as one of your own dates.
#"Open now" that survives a page cache
An "Open now, closes at 13:00" line is the most useful thing on a contacts page, and the easiest to get wrong. Computed in PHP when the page is built, it is frozen by the page cache: the page cached at 07:55 says "Closed" until the cache expires. Computed in JavaScript from the visitor's clock, it uses the visitor's time zone, so a customer abroad sees the shop open at the wrong hours.
The fix is to split the work. PHP writes the next two weeks of opening times into the page as Unix timestamps, which do not depend on any time zone, and a few lines of JavaScript pick the sentence at the moment the visitor reads it:
<?php
// Opening times from now on, as Unix timestamps, for the page to carry.
function spans(array $week, array $special, DateTimeImmutable $now, int $days = 14): array
{
$out = [];
for ($i = -1; $i < $days; $i++) {
$day = $now->modify("$i day");
$date = $day->format('Y-m-d');
$intervals = array_key_exists($date, $special) ? $special[$date] : ($week[strtolower($day->format('D'))] ?? []);
foreach ($intervals as [$opens, $closes]) {
$start = new DateTimeImmutable("$date $opens", $now->getTimezone());
$end = new DateTimeImmutable("$date $closes", $now->getTimezone());
if ($end <= $start) {
$end = $end->modify('+1 day'); // past midnight, or 00:00 to 00:00 = all day
}
if ($end > $now) {
$out[] = [$start->getTimestamp(), $end->getTimestamp()];
}
}
}
return $out;
}
function statusText(spans, now = Date.now(), timeZone = "Europe/Sofia") {
const time = (s) => new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit" }).format(s * 1000);
const day = (ms) => new Intl.DateTimeFormat("en-CA", { timeZone }).format(ms); // YYYY-MM-DD
for (const [start, end] of spans) {
if (now >= start * 1000 && now < end * 1000) return `Open now, closes at ${time(end)}`;
if (start * 1000 > now) {
const days = Math.round((Date.parse(day(start * 1000)) - Date.parse(day(now))) / 86400000);
const when = days === 0 ? "today" : days === 1 ? "tomorrow"
: new Intl.DateTimeFormat("en-GB", { timeZone, weekday: "long" }).format(start * 1000);
return `Closed, opens ${when} at ${time(start)}`;
}
}
return "Closed";
}
The PHP loop starts at yesterday, because a night that began yesterday may still be running. In WordPress, pass it current_datetime(), which carries the site's time zone from Settings, General; set that to the business's city, not to UTC. The JavaScript formats every time in the business's time zone, wherever the visitor is.
We ran both on the bakery's December. At 12:30 on 23 December it says "Open now, closes at 13:00"; at 13:30 "Closed, opens today at 14:00"; at 20:00 "Closed, opens tomorrow at 10:00", because of the short Christmas Eve; and at 15:00 on 24 December "Closed, opens Tuesday at 08:00", skipping both Christmas days, the Sunday and the Monday off. A bar open from 18:00 on Friday, checked at 01:00 on Saturday, is "Open now, closes at 02:00". With the visitor's computer set to New York time, every line came out the same.
#Putting it into WordPress
Print the JSON-LD once per location in wp_head, inside a script element of type application/ld+json, encoded with wp_json_encode(). Use the most specific LocalBusiness type that fits (Bakery, Dentist, AutoRepair), with the name, full address, coordinates and phone number exactly as they appear on the page. Put the opening times in a data attribute next to the status line and run the script when the page loads; the line written by PHP stays as the version for visitors without JavaScript.
Then check it: Google's Rich Results Test and the Schema Markup Validator read the JSON-LD and show errors. Two things the markup does not do. It does not change your Google Business Profile, which is what Maps shows, so enter the same holiday hours there. And whether a search engine shows your hours at all is up to the search engine.
#A plugin that does this
We built this into Rabotno, a WordPress plugin for opening hours that knows the Bulgarian calendar: weekly hours with up to two ranges a day, nights past midnight and round-the-clock days, the official holidays with Orthodox Easter, the replacement days off and the declared ones, a holiday policy per location and special dates that win, an "open now" line that stays right behind a cache, and a schema.org LocalBusiness block with special opening hours for the coming 60 days. It works for one or many locations, in English and Bulgarian. It was tested with 21 unit tests, the holidays from 2024 to 2031 against a separately tested calendar, an end-to-end run on WordPress 7.1.3 and a browser test of the "open now" script, and is listed as Coming soon.
#Frequently asked questions
How do I mark a day closed in schema.org opening hours?
For a regular closed day, leave it out of the weekly hours. For a closed holiday, add a special opening hours entry for that date with opens and closes both set to 00:00.
How do I write hours that go past midnight?
As one entry on the day the shift starts, for example Friday, opens 18:00, closes 02:00. Do not split it into two days.
Why does my "open now" text show the wrong status?
Usually because it was computed when the page was cached, or with the visitor's time zone. Send the opening times as timestamps and choose the text in the browser, in the business's time zone.
Do I have to close on public holidays?
That depends on your business and its staff arrangements, not on the website; ask your accountant or a labour law adviser. The website should simply show whatever you decide.
Comments
Comments
Be the first to leave a comment.
Leave a comment